-
Notifications
You must be signed in to change notification settings - Fork 3
fix: retry once on stale keep-alive sockets [CHA-4943] #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -216,7 +216,7 @@ def request(method, path, data = {}, request_timeout: nil) | |
| return make_multipart_request(method, path, query_params, data) if multipart_request?(data) | ||
|
|
||
| body_json = data.to_json | ||
| attempt = 0 | ||
| attempt = stale_retries = 0 | ||
|
|
||
| begin | ||
| started = monotonic_now | ||
|
|
@@ -238,6 +238,11 @@ def request(method, path, data = {}, request_timeout: nil) | |
| handle_response(response) | ||
| rescue Faraday::Error => e | ||
| error = TransportError.new("Request failed: #{e.message}", error_type: ErrorMapping.classify_faraday_error(e)) | ||
| if stale_retries.zero? && ErrorMapping.stale_keep_alive?(e) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This retries every HTTP method after errors that can arrive while reading the response. The server may already have applied a POST, PATCH, or DELETE, so the retry can repeat a write. I reproduced a POST whose complete body reached the server twice. Restrict automatic retries to safe methods, or require a server enforced idempotency key for writes. |
||
| log_retry_attempt(method, path, error, stale_retries, started) | ||
| stale_retries += 1 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| retry | ||
| end | ||
| if retry_eligible?(method, error, attempt) | ||
| wait_before_retry(method, path, error, attempt, started) | ||
| attempt += 1 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,16 @@ module ErrorMapping | |
|
|
||
| module_function | ||
|
|
||
| STALE_KEEP_ALIVE_PATTERN = / | ||
| connection\ reset\ by\ peer | ||
| |unexpected\ eof | ||
| |tcpsocket:\(closed\) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| |broken\ pipe | ||
| |connection\ is\ closed | ||
| |end\ of\ file\ reached | ||
| |tls_retry_write_records | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Four accepted messages have no tests: |
||
| /ix.freeze | ||
|
|
||
| # Raises the appropriate `ApiError` / `RateLimitError` for a non-2xx | ||
| # `Faraday::Response`. | ||
| def raise_api_error(response) | ||
|
|
@@ -111,7 +121,7 @@ def classify_faraday_error(error) | |
| end | ||
|
|
||
| def classify_connection_failure(error) | ||
| wrapped = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil | ||
| wrapped = wrapped_exception(error) | ||
| case wrapped | ||
| when SocketError | ||
| 'dns_failure' | ||
|
|
@@ -120,6 +130,30 @@ def classify_connection_failure(error) | |
| end | ||
| end | ||
|
|
||
| # True when the failure looks like a reused keep-alive socket that the peer | ||
| # already closed. Match the error text, not Faraday class: SSLError and | ||
| # ConnectionFailed also cover cert failures and connection refused. | ||
| # DNS failures and real read timeouts are not stale-pool errors. | ||
| def stale_keep_alive?(error) | ||
| return false if error.nil? | ||
| return false if classify_faraday_error(error) == 'dns_failure' | ||
|
|
||
| stale_keep_alive_message?(error) | ||
| end | ||
|
|
||
| def wrapped_exception(error) | ||
| return nil unless error.respond_to?(:wrapped_exception) | ||
|
|
||
| error.wrapped_exception | ||
| end | ||
|
|
||
| def stale_keep_alive_message?(error) | ||
| texts = [error.message] | ||
| wrapped = wrapped_exception(error) | ||
| texts << wrapped.message if wrapped.respond_to?(:message) | ||
| texts.compact.any? { |text| text.match?(STALE_KEEP_ALIVE_PATTERN) } | ||
| end | ||
|
|
||
| def build_task_error(task_id, error_payload) | ||
| hash = if error_payload.respond_to?(:to_h) | ||
| error_payload.to_h | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
README.mdstill says retries are opt in, disabled clients make exactly one attempt, and writes are never retried. This change makes one retry automatic and retries writes. Update the public retry section to describe the new default.