Skip to content

fix(clients): retry the remaining REST clients, and repair the operations status/cancel calls - #202

Merged
jfrench9 merged 2 commits into
mainfrom
bugfix/retry-remaining-rest-clients
Aug 31, 2026
Merged

fix(clients): retry the remaining REST clients, and repair the operations status/cancel calls#202
jfrench9 merged 2 commits into
mainfrom
bugfix/retry-remaining-rest-clients

Conversation

@jfrench9

@jfrench9 jfrench9 commented Aug 31, 2026

Copy link
Copy Markdown
Member

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 — but QueryClient, OperatorClient and OperationClient resolve the credential themselves and pass it in headers on a plain Client. 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 retrying fetch covered these by construction. Python builds a client per call, so each site had to be reached individually.

2. get_operation_status and cancel_operation could not succeed at all. Surfaced while writing a test for (1). Both read an attribute off a model carrying only additional_properties:

GetOperationStatusResponseGetoperationstatus.from_dict({"status": "completed"}).status
→ AttributeError: object has no attribute 'status'

The surrounding except Exception then shaped that into a plausible-looking failure for every response — status always returned {"status": "error"}, cancel always returned False. Both are reachable from the facade.

Changes

Commit 1 — retry parity

  • clients/retry.pyretrying_client, the unauthenticated sibling of retrying_authenticated_client. Same set_httpx_client install; 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.pytest_status_call_uses_provider_credential patched the Client constructor, 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_dict reads the body through to_dict(), matching what operator_client._poll_for_completion already does, and both methods use it.
  • 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 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 bare Mocks, where attribute access always works. They now build the real response models. Verified they fail with the AttributeError when only operation_client.py is reverted.

Deliberately excluded

  • auth_integration.py — the cookie-based login client. Backoff on the auth path is a security control, not a convenience.
  • SSE streams (sse_client, graph_client._wait_with_sse) — own reconnect logic.
  • Presigned S3 transfers (file_client._http_client, the report-bundle download in ledger_client) — not our API, not rate-limited by us.
  • Asyncset_httpx_client covers 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.

  • New export: retrying_client.
  • Unchanged: every facade signature and return type.
  • Behaviour: a query / operator / operations call that would have failed on 429 now retries (max_retries=0 restores the old behaviour). get_operation_status now returns the real status instead of {"status": "error"}; cancel_operation returns the real outcome instead of always False, and closes the stream when the cancel lands. A caller that special-cased the broken shapes should be checked — anything treating status == "error" as "poll again", or cancel_operation() is False as normal, will now see the true value.
  • Ships as a minor. Version bump is the release dispatch's job.

Testing

  • just test-all: ruff, format, basedpyright 0 errors, pytest 583 passed / 17 skipped (15 added, 3 rewritten).
  • Confirmed no plain Client( construction remains under clients/ apart from the deliberate auth_integration one.

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.
@jfrench9 jfrench9 changed the title fix(clients): retry the query, operator and operations REST calls too fix(clients): retry the remaining REST clients, and repair the operations status/cancel calls Aug 31, 2026
@jfrench9
jfrench9 merged commit faecd5c into main Aug 31, 2026
4 checks passed
@jfrench9
jfrench9 deleted the bugfix/retry-remaining-rest-clients branch August 31, 2026 22:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant