feat(client): retry transient failures, optionally indefinitely - #1323
feat(client): retry transient failures, optionally indefinitely#1323fatih-acar wants to merge 15 commits into
Conversation
…lly forever Generators that run for hours aborted whenever a single GraphQL call hit a transient infrastructure error, because retry_on_failure only covered connection errors inside execute_graphql, gave up after max_retry_duration, spun without delay on HTTP 5xx, and never looked at REST calls or at GraphQL error envelopes. Introduce a TransientRetryHandler wired into _request for both clients so connection errors, read timeouts and transient HTTP statuses are retried on every path, including query_gql_query and schema loading. execute_graphql also retries GraphQL envelopes whose errors all carry a transient extensions.http_status, sharing one time budget with the transport layer. Retries use exponential backoff with jitter capped by retry_max_delay, and max_retry_duration=0 now means retry indefinitely. retry_status_codes controls which statuses count as transient; 500 is included because Infrahub reports some transient database errors without further classification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
@@ Coverage Diff @@
## stable #1323 +/- ##
==========================================
+ Coverage 84.24% 84.77% +0.53%
==========================================
Files 147 148 +1
Lines 13068 13257 +189
Branches 1940 1951 +11
==========================================
+ Hits 11009 11239 +230
+ Misses 1494 1453 -41
Partials 565 565
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 3 files with indirect coverage changes 🚀 New features to boost your workflow:
|
Deploying infrahub-sdk-python with
|
| Latest commit: |
34f6717
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://30746e45.infrahub-sdk-python.pages.dev |
| Branch Preview URL: | https://fac-retry-on-error-7mwrc.infrahub-sdk-python.pages.dev |
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Testing retry_on_failure against a real Infrahub deployment showed the feature aborting on the exact scenario it exists for. Restarting the HAProxy container in front of the API servers during a mutation killed the operation after one attempt, even with max_retry_duration=0. A load balancer or server that goes away mid-request closes the socket before sending any response byte, and httpx reports that as RemoteProtocolError rather than a NetworkError. Only NetworkError and ReadTimeout were mapped to the SDK exceptions the retry handler treats as transient, so the failover escaped it entirely. Map RemoteProtocolError to ServerNotReachableError alongside NetworkError, on all six transport paths of both clients, via a shared CONNECTION_LOST_EXCEPTIONS tuple next to the transient classification it feeds. Add tests/integration/test_retry_on_failover.py, which reproduces the failover against a real deployment: the API is told to delay every GraphQL request, an upsert is started, and the load balancer is killed while the request is in flight. The upsert is idempotent on purpose, since the server finishes a request whose client has gone away and retrying is at-least-once. The load balancer is published on a pinned host port so its address survives the restart. All three tests fail without the mapping above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
A failover reaches the client in two shapes, and only one of them was covered. Restarting HAProxy drops the connection before any response arrives. Restarting the API servers behind it leaves the connection to the client intact, and HAProxy answers the in-flight request itself with 502 and whatever arrives while the servers boot with 503. Add a test for the second shape, asserting the mutation was retried on a 502 rather than merely that it eventually succeeded. Restarting the API servers clears the response delay, which lives in the memory of each worker process, so a fixture re-applies it afterwards and keeps the test independent of the order tests run in. Fold the duplicated restart-while-in-flight dance into restart_after, and take a sequence of container names since the API servers are replicated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…ed downloads The transient retry handler only wrapped _request, so a multipart upload or a streamed download failed on the first lost connection, timeout or retryable status even with retry_on_failure enabled, and a multipart mutation answered with a transient GraphQL error envelope was never retried at all. Wire the handler around both paths on the two clients, retrying stream initiation the way the 429 handler already does, and give _execute_graphql_with_file the same envelope retry loop as execute_graphql on one shared budget, rewinding the file before every attempt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… checker Spell out the setting names in the configuration descriptions and avoid the terms the vale spelling rule rejects in the client guide. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 8 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…pt-in The synchronous failover test ran an unlimited-retry save with no time guard, unlike its async twins bounded by asyncio.wait_for, so a load balancer that never came back would have hung the whole suite. Run the save in a daemon thread and fail after RETRY_TIMEOUT instead. The teardown that re-applies the response delay after the API servers restart now waits for them to answer first: the delay is set by a broadcast that only reaches workers that are up. The module is skipped unless INFRAHUB_TESTING_FAILOVER=1 is set, so CI never runs it: the tests restart containers, pin a host port and take several minutes. The tests guide documents how to opt in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…end carries the full body A retried upload re-sends the file from the start, which a pipe, a socket or another non-seekable stream cannot do: the rewind before each attempt silently fails and the retry carries an empty body. Copy such a stream to a temporary file before the first attempt and upload from that copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… be retried The streaming path read and closed every transient response as soon as retries were enabled, even once the retry budget was spent and the handler was about to hand that response back. Decide with the shared retry state instead, so a response that will not be retried reaches the caller open and its body is not fetched needlessly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pires Failing the test left the worker thread retrying the save with an unlimited budget against the live deployment, where it could complete later and keep logging behind the next test. On timeout the guard now turns the client's retries off, so the next failed attempt raises and the thread ends, and waits a grace period for that before failing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…nc client Draining a stream that cannot be rewound is blocking I/O; done inline it stalled every other task on the loop until the whole source was read. The async upload path now performs that copy in a worker thread; the synchronous client keeps the plain copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e copy Keep the None check in the context managers, where it narrows the stream for the copy, and run the async copy through a small closure that the thread offload can type; the test counts the offloaded call instead of naming it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…re closing the buffer Cancelling the async upload while a non-seekable source was being copied propagated at once and closed the temporary file while the worker thread was still writing to it. Shield the copy and, on cancellation, wait for the thread to end before re-raising, so no worker is left on a closed resource. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…ncellations A second cancellation arriving while the drain waited on the copy task cancelled that wait too, and the temporary file was closed under the worker thread after all. Drain in a loop that absorbs cancellations until the thread has ended, then re-raise the cancellation the caller holds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| retry_delay: int = Field( | ||
| default=5, | ||
| ge=0, | ||
| description=( | ||
| "Base delay in seconds before retrying a request that failed with a transient error. " | ||
| "The delay doubles after every attempt, with jitter, up to the maximum retry delay." | ||
| ), | ||
| ) | ||
| retry_max_delay: int = Field( | ||
| default=60, | ||
| ge=0, | ||
| description="Maximum delay in seconds between two retries of a request that failed with a transient error.", | ||
| ) |
There was a problem hiding this comment.
both of these settings use ge=0, and compute_backoff returns min(max_delay, ...), so either zero makes every retry sleep 0 seconds. Managed to make 20000+ attempts in one second against a persistent 502.
| while not task.done(): | ||
| try: | ||
| await asyncio.wait({task}) | ||
| except asyncio.CancelledError: | ||
| continue |
There was a problem hiding this comment.
What happens if the source stream never returns from read()? It feels like things could get stuck here even at default config, with retries off.
| TRANSIENT_EXCEPTIONS = (ServerNotReachableError, ServerNotResponsiveError) | ||
| """Client-side failures (connection error, read timeout) that are always considered transient.""" | ||
|
|
||
| CONNECTION_LOST_EXCEPTIONS = (httpx.NetworkError, httpx.RemoteProtocolError) |
There was a problem hiding this comment.
Should ConnectTimeout, WriteTimeout and PoolTimeout be in that sequence to be retried too? WriteTimeout is used by the upload path this PR tries to harden.
| except CONNECTION_LOST_EXCEPTIONS as exc: | ||
| raise ServerNotReachableError(address=self.address) from exc |
There was a problem hiding this comment.
Is this the right exception for a connection lost mid-download?
The catch around the yield now includes RemoteProtocolError, so a transfer truncated while the caller reads the body surfaces as "Unable to connect to ..." with no retry, and file_handler leaves a partial file with no hint, even though the server was reachable in the first place.
| @@ -0,0 +1 @@ | |||
| With `retry_on_failure` enabled, the client now retries every transient failure instead of only connection errors: a connection dropped before any response arrives (the shape a load balancer failover or a server restart takes on an in-flight request), read timeouts, HTTP `500`/`502`/`503`/`504` responses and GraphQL errors the server flags with one of those statuses (for example a database that became unavailable mid-run). Retries apply to every request path, including multipart uploads, streamed downloads and REST endpoints such as `query_gql_query` used by generators, and use exponential backoff with jitter (`retry_delay` as the base, capped by the new `retry_max_delay`) instead of retrying HTTP 5xx responses in a tight loop without any delay. The new `retry_status_codes` setting tunes which statuses count as transient, and `max_retry_duration=0` retries indefinitely so long-running generators can survive an outage rather than abort. Retries are logged with the attempt number and elapsed time, escalating from `WARNING` to `ERROR` after five minutes, and once the budget is exhausted the original error is raised instead of a generic one. | |||
There was a problem hiding this comment.
This can probably be shorten without losing its meaning.
Summary
Generators and other long-running SDK jobs abort as soon as a single GraphQL call hits a transient infrastructure error, even though the task worker already runs the client with
retry_on_failure=True. That switch only covered connection errors insideexecute_graphql, gave up after 5 minutes, spun without any delay on HTTP 5xx, and never looked at REST calls or at GraphQL error envelopes. This PR turnsretry_on_failureinto a real transient-error policy and lets operators opt into retrying indefinitely, so a multi-hour generator survives a database failover or a network outage instead of failing.Key changes
retry_on_failureenabled, connection errors, read timeouts, HTTP 500/502/503/504 responses and GraphQL errors the server flags with one of those statuses are retried. Everything else still fails fast, so a bad query or a schema error surfaces immediately even in unlimited mode.RemoteProtocolErrorrather than a network error, so it used to escape the retry handler entirely; it is now mapped toServerNotReachableErrorlike any other lost connection, on all six transport paths of both clients. See the section below for how this was found.query_gql_querydata collection, schema loading and the other REST endpoints. The GraphQL-envelope retry shares one time budget and attempt counter with the transport layer.retry_delayis the base and the newretry_max_delaycaps it. This also fixes the 5xx busy loop.max_retry_duration=0now means retry indefinitely. On the task worker,INFRAHUB_MAX_RETRY_DURATION=0is enough to opt in because the worker already setsretry_on_failure=True.retry_status_codessetting controls which statuses count as transient. 500 is included because Infrahub reports some transient database errors (Neo4j transient or session errors exhausting the server-side retries) without further classification.client.retry_on_failureandclient.retry_delaybecome properties so they can still be toggled at runtime, for example by a generator.Verified against a real failover
The policy was exercised against a real Infrahub deployment from
infrahub-testcontainers(two API replicas behind HAProxy, plus Neo4j, RabbitMQ, Redis and Prefect) rather than against mocks alone. The server's/api/response-delayendpoint makes every GraphQL request sit for 10 seconds, which is a wide enough window to kill a container while a mutation is genuinely in flight.That found the feature aborting on the exact scenario it exists for. With
retry_on_failure=Trueandmax_retry_duration=0, restarting HAProxy during a mutation killed the operation after a single attempt:A load balancer that goes away mid-request closes the socket before sending a single byte of the response, and httpx reports that as
RemoteProtocolError, aProtocolErrorrather than aNetworkError. OnlyNetworkErrorandReadTimeoutwere mapped to the SDK exceptions the retry handler treats as transient, so a failover bypassed the handler entirely: zero retries, unlimited budget notwithstanding. With the mapping added here, the same failover only delays the mutation:A failover reaches the client in one of two shapes, depending on which side of the load balancer goes away. Both are now covered:
RemoteProtocolError)retry_status_codes, which already workedThe at-least-once caveat below turned out not to be theoretical. The first run used a plain create and the retry came back with
Violates uniqueness constraint 'name': the server keeps processing a request whose client has gone away, so attempt one had already written the node before the socket closed. The integration tests usesave(allow_upsert=True)for that reason.Caveats
retry_status_codesto fail fast on it..infrahub.ymlneeds an Infrahub-side change to forward the flag to the client and is left as a follow-up.Documentation
docs/docs/python-sdk/guides/client.mdx: new "Retry transient failures" section under advanced use cases, listing a dropped connection among the transient failures.docs/docs/python-sdk/reference/config.mdxanddocs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx.changelog/+transient-retry.added.md,changelog/+retry-5xx-busy-loop.fixed.mdandchangelog/+retry-connection-dropped-mid-request.fixed.md.Test plan
uv run pytest tests/unit/sdk/test_retry.py: 78 tests covering classification, backoff, budget and unlimited mode, the shared budget across layers, the opt-in default, runtime toggling, environment plumbing and async/sync parity. 12 of them are new and drive a lost connection through httpx itself, so theRemoteProtocolErrormapping is covered without needing Docker.uv run pytest tests/integration/test_retry_on_failover.py: 4 tests against the deployment described above, 3m30s. Three restart HAProxy mid-mutation (retries disabled, async retry-forever, sync retry-forever) and all three fail without the mapping, so they are a real regression test rather than a demonstration. The fourth restarts the API servers instead and asserts the retry happened on a 502, not merely that the mutation eventually succeeded.uv run invoke lint-codepasses.uv run invoke lint-docspasses rumdl; vale was not available locally.tests/unit/ctlfailures.🤖 Generated with Claude Code
Summary by cubic
Turns
retry_on_failurein theinfrahub_sdkclient into a real transient-error policy. Previously it only retried connection errors insideexecute_graphql, capped at 5 minutes, and never covered REST calls, GraphQL error envelopes, the no-delay 5xx busy loop, or connections dropped before a response arrived; now connection errors, dropped connections, read timeouts, HTTP 500/502/503/504 responses, and GraphQL envelopes flagged with those statuses are retried on every request path—including multipart uploads and streamed downloads—with exponential backoff and jitter, optionally forever.Key changes
retry_max_delaycaps the backoff;retry_status_codestunes which statuses count as transient (500 is in the default set because Infrahub reports some transient DB errors without classification).retry_on_failureandretry_delaycan now be toggled at runtime.ServerNotReachableErrorand retried; opt-in integration tests (INFRAHUB_TESTING_FAILOVER=1) restart HAProxy mid-mutation to cover it, with the sync case time-bounded.Caveats
retry_status_codesif you want genuine bugs to fail fast instead of retrying until the budget expires..infrahub.ymlis left as a follow-up.Written for commit 34f6717. Summary will update on new commits.