Repository navigation
[Feature] Modernization: one requests transport with base_url, OAuth, adaptive rate limits and typed errors - #98
Merged
Merged
Conversation
This was referenced Oct 21, 2026
Merged
fostimus
force-pushed
the
df/modernize-transport
branch
from
October 21, 2026 22:18
1f5b51f to
5bf0921
Compare
fostimus
force-pushed
the
df/modernize-transport
branch
from
October 27, 2026 22:08
5bf0921 to
3f9c1a1
Compare
This comment has been minimized.
This comment has been minimized.
fostimus
force-pushed
the
df/modernize-transport
branch
from
October 28, 2026 17:46
4549917 to
7e235a4
Compare
One transport sends every request, the way node-anvil's does: - `Anvil(api_key, base_url=...)` derives every URL from one host, REST and GraphQL alike. `endpoint_url` and the separate graphql.useanvil.com host are gone. OAuth access tokens are accepted (`access_token`), sent as-is when they are JWTs and base64 encoded otherwise. - Rate limits come from the first response's X-RateLimit headers, over a sliding window like the API's, and a 429 is retried after its Retry-After plus 50ms. Requests made before the first response wait for it. The limiter is thread-safe. - API errors raise AnvilRequestError with the normalized errors, status code, headers and any partial data, for REST and GraphQL alike. Methods return the data itself. - Multipart uploads follow the GraphQL multipart request spec directly. `prepare_graphql_file` takes a path, bytes or a file object; files given as paths are reopened for a retry, file objects rewound. - The queries and mutations live in python_anvil.graphql, copied from node-anvil's src/graphql, and are validated against the API schema from @anvilco/types in the tests. That check found that `casts` and `welds` are paginated now, which the old queries didn't match. - New: get_etch_packet, remove_weld_data, request_graphql, request_rest, response_query and mutation overrides, data_type="stream", a request timeout, and test-client.py for running every method against staging. The HTTP tests run the real client against a local server, so the auth encoding, multipart bodies, rate limit timing and error handling are checked on the wire. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Limits are taken from the first response that has the rate limit headers, not the first response: a proxy error or a 401 served before the API's middleware no longer fixes the client at 1 request/s for good. - A 429 with no usable Retry-After waits a second, not 50ms, so a 429 from a CDN with no header can't become a tight loop. - A 2xx JSON body with a `name` or `message` key is data, not an error; only `errors` or a non-2xx status is. A non-JSON error keeps the start of its body, including on the stream path. - A full URL on another host is refused rather than prefixed with the base URL, as FullyQualifiedRequest refused it. - `environment` is still accepted positionally, with the warning. - A builder's `mutation_res_query` still selects its response fields. - AnvilRequestError accepts a plain message, as the old exception did. - A plain variable that happens to be called `file` is sent as JSON. - `close()` leaves a caller-supplied session open. - `request_rest` explains itself when JSON is asked of a binary endpoint. - `ForgeSubmitPayload.webhook_url` is a `str`: as an `HttpUrl` the JSON encoder rejected it, before 6.0.0 too. - The changelog names every removed public name and the behavior changes the audit found undocumented: per-instance limits, no 5xx retries for GraphQL, and forgeSubmit's changed default fields. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ntials Both as the 5.x client did, rather than as node-anvil does: - Every request is retried on 500, 502, 503, 504 and connection errors, up to `max_retries` (5) times with exponential backoff from 100ms, the policy gql's transport applied to GraphQL requests. REST requests get it too now. Mutations are retried, as before; `max_retries=0` turns it off. Retries go through the rate limiter like any request, and a retried upload is rebuilt from its files. - The API's limit is per account, so every client in the process with the same base URL and credentials shares one limiter, and the limits the first response taught. A web app that makes a client per request is limited as a whole again, as it was with the `ratelimit` decorator. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After the first request has taught the client the account's limit, send two more requests than it allows at once, from threads. The client must hold the extra ones back for a full interval, and none may get a 429. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sponse The API counts a request from when it arrives, which the client can't see: it is somewhere between when the request was sent and when the response came back. The limiter counted from when it was sent, with a 50ms buffer, which a new connection's TCP and TLS handshake alone can exceed. So a request the API still had in its window could already have left the client's, and test-client.py's burst got a 429. A request now counts from when it is sent until an interval after its response arrives (or after it fails, since it may have reached the API). The API's arrival time always lies in between, so it never counts more requests in its window than the client allowed, whatever the latency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- A raw `data=` file object is rewound for a retry, and one that can't be is refused, rather than sent again empty. With 5xx retries on by default, a retried upload through request_rest would have succeeded with no body. - The slot a request takes in the rate limiter is given back even when an interrupt lands between taking it and sending; a leaked slot on a shared limiter with a limit of 1 would have blocked every client with those credentials for good. - The shared-limiter registry holds each limiter only as long as a client does, and is keyed on a hash of the credentials, so a service that makes a client per OAuth token neither grows without bound nor keeps every token it has seen. - A Retry-After header is capped at a minute, and one that isn't a finite number of seconds falls back to the default, instead of a proxy's mistake blocking a thread or crashing in time.sleep. - Read timeouts and connections dropped mid-response are deliberately not retried, since the API may have processed the request; the docs now say so instead of claiming gql's exact policy. - The backoff tests check upper bounds too, pytest-timeout turns a limiter hang into a failure, and the new parameters and paths have tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fostimus
force-pushed
the
df/modernize-transport
branch
from
October 28, 2026 17:52
7e235a4 to
8d20ae8
Compare
fostimus
marked this pull request as ready for review
October 28, 2026 17:52
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
Layer 2 of 4, on #97. One
requests-based transport replacesgqlfor GraphQL and theratelimitdecorators for REST, bringing the client to node-anvil's behavior.Anvil(api_key, base_url=...)sends every request to one host, REST and GraphQL alike; the hardcoded graphql.useanvil.com host andendpoint_urlare gone. OAuth access tokens are accepted (access_token), sent as-is when they are JWTs and base64 encoded otherwise.X-RateLimit-*headers, counted over a sliding window like the API's; requests made before the first response wait for it. A request counts from when it is sent until an interval after its response arrives, because the API counts from when the request reached it, which is in between: the first staging run of the rate limiting check got a 429 when the client counted from send time, since a new connection's handshake put a request on the wrong side of the window. Every client in the process with the samebase_urland credentials shares one limiter, as the account's limit is shared, so an app that makes a client per request is limited as a whole, as it was with theratelimitdecorator. A 429 is retried after itsRetry-Afterplus 50ms, or after a second without one. The limiter is thread-safe.max_retries(5) times with exponential backoff, the policy gql's transport applied to GraphQL requests in 5.x, now for REST too. Mutations are retried as before;max_retries=0turns it off.AnvilRequestErrorwith the normalized errors (GraphQL errors, REST errors, validation errors with their fields), status code, headers and any partial data. Methods return the data itself: PDF bytes, or the GraphQLdatadict.include_headers,debugandretryare gone.Anvil.prepare_graphql_filetakes a path, bytes or a file object; a retried upload reopens a path and rewinds a file object, and fails clearly for one that can't be read again.python_anvil.graphql, copied from node-anvil'ssrc/graphql, and are validated against the schema from@anvilco/typesin the tests. That check found thatcastsandweldsare paginated now, which the oldget_castsandget_weldsqueries no longer matched.get_etch_packet,remove_weld_data,request_graphql,request_rest,response_queryandmutationoverrides,data_type="stream", and a 5 minute request timeout.test-client.pyfrom [Refactor] Modernization: Python 3.10-3.15, ruff, PEP 621 packaging and tag-triggered publishing #97 is updated to the 6.0 API. Its diff in this PR is the list of behavior changes a caller sees: the constructor,request_rest's signature,include_headersgone,generate_etch_signing_urlreturning the URL, and the GraphQL methods that replace raw queries. It keeps the 5.x upload path (an open file base64 encoded into the variables) alongside the new multipart one, and the convenience queries, and adds a rate limiting check: after the first request has taught the client the account's limit, it sends two more requests than the limit allows at once and checks that they took at least an interval and that none got a 429.An audit against 5.1.2 (one pass by hand, one by an independent reviewer with the diff and the changelog) found nothing in the methods themselves, and a handful of edge cases, fixed in the last commit: limits are taken from the first response that has the headers rather than the first response; a 429 with no
Retry-Afterwaits a second instead of 50ms; a 2xx JSON body with anamekey is data, not an error; a full URL on another host is refused;environmentstill works positionally; a builder'smutation_res_querystill works; andforge_submitwith awebhook_urlworks, which it did not in 5.x. The changelog now names every removed public name. Two things the audit flagged as node-anvil parity were instead kept as 5.x had them, since they are the better behavior for Python callers: 5xx and connection errors are retried, and the rate limiter is shared per credentials rather than per client instance.A second audit of the retry and shared-limiter commits found one bug, fixed in the last commit: a raw
data=file object passed torequest_restwas re-sent empty on a retry. It also hardened the rest: a limiter slot can't leak on an interrupt, the shared-limiter registry is weak and keyed on a hash of the credentials,Retry-Afteris capped at a minute and must be a finite number, and read timeouts are deliberately not retried.The HTTP tests run the real client against a local server (
tests/server.py), so the auth encoding, multipart bodies, rate limit timing and error handling are checked on the wire rather than through mocks.How to verify
ANVIL_API_KEY=<staging key> make test-clientreports PASS for every method against staging, including the rate limiting check, which prints the limit learned from the API and the time a burst over it took, including the base64 upload path andget_casts/get_welds, which FAIL on [Refactor] Modernization: Python 3.10-3.15, ruff, PEP 621 packaging and tag-triggered publishing #97 (not run here: no staging key in this environment)base64(api_key + ":"); a JWT access token is sent asBearer <token>as-is, any other access token base64 encodedbase_url:/api/v1/fill/{id}.pdf,/api/v1/generate-pdf,/api/document-group/{eid}.zipand/graphqlX-RateLimit-LimitandX-RateLimit-Interval-Msset the limits; concurrent requests wait for that first response; no more than the limit is sent in any intervalRetry-Afterplus 50ms, and retried again if the retry is also a 429AnvilRequestErrorwithstatus_code == 200, the errors list and the partialdatafields; a non-JSON 502 raises with the status text; an empty JSON body raises aJSONDecodeErrorerrorprepare_graphql_fileis sent asoperations,mapand a file part with the right filename and mimetype; a base64 dict is sent as plain JSONfill_pdf(..., data_type="stream")returns arequests.Responsethat streams the PDFgenerate_etch_signing_urlreturns the URL string;get_etch_packetthe packet;remove_weld_dataa boolnamekey is returned as data, andrequest_rest("GET", "https://other.host/...")raises a ValueErrorrequest_rest("POST", ..., data=<file object>)that gets a 503 is retried with the full body, and a body that can't be rewound raises instead of being sent emptyRetry-Afterof1e9on a 503 waits 60 seconds, not 31 years;nanfalls back to backoffmax_retries=0sends once; a retried upload is sent in full againcreate_etch_packet({"name": ..., "webhookURL": ..., "mergePDFs": True})andforge_submit({"forgeEid": ..., "payload": {}})accept the camelCase keys the docstrings promisedAnvil(key, "prod")works with a DeprecationWarning, andforge_submit({"forge_eid": ..., "payload": {}, "webhook_url": "https://..."})sends the URLBreaking Changes
A major release (6.0.0). The constructor, return values, error type,
request_restsignature and removed classes are listed under Unreleased inCHANGELOG.md.Open workspace in Conductor