Conversation
… other call KB::Listable.all re-raised Faraday::ConnectionFailed raw (deliberately, since 2021) while every other Faraday error, and every other model concern, wraps into KB::Error. Consumers' `rescue KB::Error` handlers therefore missed connection failures from PetParent.all / Pet.all / Breed.all / Product.all, and since 1.0.0 that includes TLS-handshake timeouts. No consumer rescues the raw class. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…an have reached KB (1.3.0) KB::RetryPolicy classifies by the underlying error rather than the Faraday class, so it holds for both the net_http and the persistent adapter: never-sent failures (Net::OpenTimeout, ECONNREFUSED, EHOSTUNREACH, ENETUNREACH, EADDRNOTAVAIL, SocketError) retry for every verb; other transport failures retry for GET/HEAD only; HTTP responses never retry. One retry by default (KB.config.request.retries, retry_interval 0.1s randomized to 2x). Retries are reported on the call's request.kb_client event (retries, retry_errors) through the Faraday request context, and tagged on the kb.client.request span as kb.retries / kb.retry_errors. Without this a retried connect failure would vanish from Datadog: it produces no http.request span. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fatbeard2
left a comment
There was a problem hiding this comment.
🤖 Agent review
The change is solid and does what it says. I checked it against faraday-retry 1.0.3, faraday-net_http 1.0.1 and Faraday 1.10.3. The net_http adapter wraps OpenTimeout, ECONNREFUSED and SocketError as ConnectionFailed and ReadTimeout/WriteTimeout as TimeoutError, and root_cause reads the original error through wrapped_exception. raise_error only raises plain ServerError/ClientError, which the retry matcher does not catch, so HTTP responses are never retried.
The request-context mechanism is thread-safe. build_request dups the options per request, and faraday-retry reuses the env across attempts, so the context reaches retry_block. The backoff numbers match the docs, and max: 0 stays 0.
The Listable change is correct. It does break code that rescues the raw Faraday class, which strictly calls for a major version, but the consumers were checked and the PR says so.
Points not tied to one line:
(a) retries and retry_interval are read once, when each client builds its memoized connection, like the timeouts. The README should say so.
(b) Errno::ETIMEDOUT is treated as maybe-sent. That is conservative, just not called out.
| # Options for faraday-retry's middleware, read from KB.config.request. | ||
| def middleware_options | ||
| { | ||
| max: KB.config.request.retries, |
There was a problem hiding this comment.
🤖 [should-fix] The retry also applies to per-call read_timeout: overrides. The README's own request('birthdays', ..., read_timeout: 30) example can now take about 2 × 30s plus the connect/write budgets before it fails. Either skip the maybe-sent retry when read_timeout is overridden, or at least document the doubled worst case next to that example.
| max: KB.config.request.retries, | ||
| interval: KB.config.request.retry_interval, | ||
| interval_randomness: 1, # 1x-2x the interval, so a burst of callers doesn't retry in lockstep | ||
| exceptions: TRANSPORT_ERRORS, |
There was a problem hiding this comment.
🤖 [nit] faraday-retry is declared as a dependency but never required. The gem relies on Faraday 1.10's faraday.rb loading it. On Faraday 1.0–1.9, :retry resolves to the built-in Faraday::Request::Retry instead. Add require 'faraday/retry' or pin faraday >= 1.10.
| # - never sent: TCP connect / TLS handshake did not complete, so KB cannot have | ||
| # seen the request. Safe to retry for every verb, POST included. | ||
| # - maybe sent: anything else the transport raises (read/write timeout, reset, | ||
| # EOF, TLS error mid-stream). KB may have processed it, so only GET is retried. |
There was a problem hiding this comment.
🤖 [nit] The comment says "only GET is retried", but MAYBE_SENT_VERBS also includes HEAD, as do the README and spec. Say "GET/HEAD".
| # Hands the request.kb_client event payload to the middleware through the | ||
| # request context, so each retry is reported on the call's own event. | ||
| def track(request, event) | ||
| request.options.context = { kb_event: event } |
There was a problem hiding this comment.
🤖 [nit] Assigning context replaces any context the connection-level request options already carry. Nothing sets one today, but merging costs nothing and keeps this safe later.
| client.find('k') | ||
|
|
||
| expect( | ||
| status: last_span.status, retries: last_span.get_metric('kb.retries') || last_span.get_tag('kb.retries'), |
There was a problem hiding this comment.
🤖 [nit] get_metric || get_tag accepts either storage, so the spec doesn't pin it. Assert get_metric only, so a regression to a string tag, which breaks numeric facets, would fail the spec.
- A call that raised its own read_timeout: (birthdays, 30s) is no longer retried on maybe-sent failures, so its worst case isn't 2 x 30s. Never-sent failures still retry. - Require faraday/retry explicitly instead of relying on Faraday 1.10 loading it. - Merge into the request context instead of replacing it. - Comments/README: GET/HEAD (not just GET), why DELETE is excluded, and that retry settings are read when a client builds its connection. - Datadog spec pins kb.retries as a numeric metric. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
🤖 Agent review addressed in f73bd4b: a call with its own |
Why?
About 0.1% of KB calls fail in the transport, in morning bursts, and a caller sees every one of them as an error. Most of these failures never reached KB, so a second attempt on a fresh connection is safe and very likely succeeds. Since the 1.0.0 rollout, Funnel's KB connect failures sit at exactly the 1s connect budget (
Net::OpenTimeout), which is the safest case to retry.Changes
Retries (
KB::RetryPolicy, faraday-retry in the connection stack). One retry by default. The decision uses the underlying Ruby error, not the Faraday class, because the net_http and persistent adapters wrapNet::OpenTimeoutdifferently:Net::OpenTimeout(TCP connect + TLS),ECONNREFUSED,EHOSTUNREACH,ENETUNREACH,EADDRNOTAVAIL,SocketErrorECONNRESET,EOFError, TLS errorspec/retry_policy_spec.rbpins the full error × verb matrix.Open decisions from the todo, and what I chose:
upsertandPetParent#merge!are PUTs whose second run changes KB state or fails.KB.config.request.retries(0 disables) andretry_interval.read_timeout:(birthdays, 30s) are not retried on "maybe sent" failures, so their worst case isn't doubled. Never-sent failures still retry. This came from the agent review.Latency envelope. Worst case is two attempts' worth of phase budgets plus the interval. A GET that read-times-out twice takes about 2 × (1 + 3 + 5)s with the defaults. A connect failure costs at most 2 × 1s. connected_health's circuit breaker sees one failure only after both attempts fail.
Instrumentation. Retries are reported on the call's own
request.kb_clientevent asretriesandretry_errors, e.g.["Net::OpenTimeout"], through the Faraday request context. The Datadog subscriber tags them askb.retries/kb.retry_errors. Both appear only on retried calls, so payloads of non-retried calls are unchanged. This matters for measurement:kb.client.requestwraps all attempts, sokb.client.errorswill count only calls that failed after the retry. A connect-phase attempt produces nohttp.requestspan, so the tag is the only trace of retried connect failures. A span withkb.retriesand no error is a failure the retry absorbed.Listable fix (separate commit).
KB::Listable.allre-raisedFaraday::ConnectionFailedraw, on purpose since 2021 (fbea43d), while every other model call wraps intoKB::Error. Sorescue KB::Errorhandlers, like connected_health'srescue_from, missed connection failures fromPetParent.all(email:),Pet.all,Breed.all,Product.alland others. Since 1.0.0 that includes TLS-handshake timeouts. I checked Funnel, Global Admin and connected_health masters, and none of them rescues the raw class.Released as 1.3.0. faraday-retry is now an explicit dependency. It was already in every consumer's bundle through Faraday 1.10 (1.0.3 / 1.0.4).
Checklist
lib/kb/version.rbupdatedHow to test
Automated tests cover it. From the repo root:
Result: 213 examples, 0 failures; rubocop clean on
libandspec. CI is green.spec/retry_policy_spec.rb: the error × verb matrix.spec/client_retries_spec.rb: end to end through WebMock. It covers a GET connect timeout that retries and succeeds with the retry recorded on the event, a POST retried on connection refused, a POST not retried on a read timeout, no retry on a 503, giving up after one retry, andretries = 0.spec/instrumentation/datadog_spec.rb:kb.retries/kb.retry_errorson the span. The existing closed-port spec now also shows a realECONNREFUSEDon a real socket being retried.spec/models/concerns/listable_spec.rb:ConnectionFailedfrom.allbecomesKB::Error.In production, once a consumer bumps:
operation_name:kb.client.request @kb.retries:*in Trace Explorer shows absorbed failures.Upgrade Deployment Instructions
barkibu-kb ~> 1.3with a conservative lock update.Faraday::ConnectionFailedaround.allmust rescueKB::Errorinstead. None of our three consumers does.🤖 Generated with Claude Code