fix(clients): retry the remaining REST clients, and repair the operations status/cancel calls - #202
Merged
Merged
Conversation
The 429 replay landed on every facade that builds an `AuthenticatedClient`, which is what the first pass grepped for. The query, operator and operations clients resolve the credential themselves and pass it in `headers` on a plain `Client`, so they were missed — four call sites that still surfaced a rate limit as an ordinary failure. They are not incidental paths. Cypher queries, operator runs and operation-status polls each draw on their own category budget, and polling an operation in a loop is exactly the shape that exhausts one. This also brings the Python client level with the TypeScript one, where every generated op shares a single client and was covered by construction. `retrying_client` is the unauthenticated sibling of `retrying_authenticated_client`. The cookie-based client in `auth_integration` is deliberately left alone: backoff on the login path is a security control, not a convenience. The existing status-call test patched the `Client` constructor, which this routes around. It now asserts the credential on the client actually handed to the generated op, which is the behaviour it meant to pin and does not depend on how the client is built.
`get_operation_status` and `cancel_operation` could not succeed. Both
read an attribute off a model that carries only `additional_properties`
— the operations endpoints are free-form objects, so that is all the
generator emits — and the surrounding `except Exception` shaped the
AttributeError into a plausible result for *every* response, successful
ones included. Status always returned {"status": "error"}, cancel always
returned False.
This is the second incarnation of the bug the comment in that same
method warns about: a TypeError laundered into a fake result, hidden by
the breadth of the except. `operator_client._poll_for_completion`
already reads these through `to_dict()`; `_parsed_dict` makes that the
shared accessor.
cancel_operation had a second defect the first one masked. Its SSE
cleanup sat after an early `return` on the success path, so the one case
that needs the stream closed — the cancel actually landed — was the case
that skipped it. Because the AttributeError meant that return was never
reached, the cleanup was dead code outright; fixing only the accessor
would have made it permanently dead on the common path. It now runs
before returning the outcome.
The pre-existing tests passed against all of this because they asserted
on bare Mocks, where attribute access always works. They now build the
real response models, and fail against the old logic with the
AttributeError.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two commits, both follow-ups to #201, kept in one PR because they touch the same file.
1. The 429 replay reaches the last four REST call sites. #201 covered every facade that builds an
AuthenticatedClient— which is what I grepped for — butQueryClient,OperatorClientandOperationClientresolve the credential themselves and pass it inheaderson a plainClient. Cypher queries, operator runs and operation-status polls each draw on their own category budget, and polling an operation in a loop is exactly the shape that exhausts one.This also closes a divergence between the SDKs: in the TypeScript client every generated op resolves
(options.client ?? client)against one shared singleton, so installing the retryingfetchcovered these by construction. Python builds a client per call, so each site had to be reached individually.2.
get_operation_statusandcancel_operationcould not succeed at all. Surfaced while writing a test for (1). Both read an attribute off a model carrying onlyadditional_properties:The surrounding
except Exceptionthen shaped that into a plausible-looking failure for every response — status always returned{"status": "error"}, cancel always returnedFalse. Both are reachable from the facade.Changes
Commit 1 — retry parity
clients/retry.py—retrying_client, the unauthenticated sibling ofretrying_authenticated_client. Sameset_httpx_clientinstall; no credential stamping, since these callers supply their own headers.clients/query_client.py(1),clients/operator_client.py(1),clients/operation_client.py(2) — now build through it.tests/test_auth_header_resolution.py—test_status_call_uses_provider_credentialpatched theClientconstructor, which this routes around. It now asserts the credential on the client actually handed to the generated op — the behaviour it meant to pin, independent of how the client is built.Commit 2 — the operations response body
clients/operation_client.py—_parsed_dictreads the body throughto_dict(), matching whatoperator_client._poll_for_completionalready does, and both methods use it.cancel_operationhad a second defect the first one masked. Its SSE cleanup sat after an earlyreturnon the success path, so the one case that needs the stream closed — the cancel actually landed — was the case that skipped it. Because the AttributeError meant thatreturnwas never reached, the cleanup was dead code outright; fixing only the accessor would have made it permanently dead on the common path. It now runs before the outcome is returned.tests/test_operation_client_status.py(new) — 12 tests over both methods: real status values, a failed operation's error, missing/absent body, transport failure, cancel true/false, and the stream being closed on a successful cancel.tests/test_operation_client_ops.py— the pre-existing tests passed against all of the above because they asserted on bareMocks, where attribute access always works. They now build the real response models. Verified they fail with theAttributeErrorwhen onlyoperation_client.pyis reverted.Deliberately excluded
auth_integration.py— the cookie-based login client. Backoff on the auth path is a security control, not a convenience.sse_client,graph_client._wait_with_sse) — own reconnect logic.file_client._http_client, the report-bundle download inledger_client) — not our API, not rate-limited by us.set_httpx_clientcovers the sync client only. Every facade is sync, so nothing in-tree hits it.Compatibility
ADDITIVE surface. Two runtime-behaviour changes, both on paths that previously could not work.
retrying_client.max_retries=0restores the old behaviour).get_operation_statusnow returns the real status instead of{"status": "error"};cancel_operationreturns the real outcome instead of alwaysFalse, and closes the stream when the cancel lands. A caller that special-cased the broken shapes should be checked — anything treatingstatus == "error"as "poll again", orcancel_operation() is Falseas normal, will now see the true value.Testing
just test-all: ruff, format,basedpyright0 errors,pytest583 passed / 17 skipped (15 added, 3 rewritten).Client(construction remains underclients/apart from the deliberateauth_integrationone.