Skip to content

[Feature] Modernization: one requests transport with base_url, OAuth, adaptive rate limits and typed errors - #98

Merged
fostimus merged 6 commits into
masterfrom
df/modernize-transport
Oct 28, 2026
Merged

fostimus merged 6 commits into
masterfrom
df/modernize-transport

Conversation

@fostimus

@fostimus fostimus commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Layer 2 of 4, on #97. One requests-based transport replaces gql for GraphQL and the ratelimit decorators 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 and endpoint_url 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 with 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 same base_url and 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 the ratelimit decorator. A 429 is retried after its Retry-After plus 50ms, or after a second without one. The limiter is thread-safe.
  • Every request is retried on 500, 502, 503, 504 and connection errors, up to 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=0 turns it off.
  • API errors raise AnvilRequestError with 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 GraphQL data dict. include_headers, debug and retry are gone.
  • Multipart uploads follow the GraphQL multipart request spec directly. Anvil.prepare_graphql_file takes 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.
  • The queries and mutations live in python_anvil.graphql, copied from node-anvil's src/graphql, and are validated against the schema from @anvilco/types in the tests. That check found that casts and welds are paginated now, which the old get_casts and get_welds queries no longer matched.
  • New: get_etch_packet, remove_weld_data, request_graphql, request_rest, response_query and mutation overrides, data_type="stream", and a 5 minute request timeout.
  • test-client.py from [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_headers gone, generate_etch_signing_url returning 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-After waits a second instead of 50ms; a 2xx JSON body with a name key is data, not an error; a full URL on another host is refused; environment still works positionally; a builder's mutation_res_query still works; and forge_submit with a webhook_url works, 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 to request_rest was 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-After is 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-client reports 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 and get_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)
  • Basic auth is base64(api_key + ":"); a JWT access token is sent as Bearer <token> as-is, any other access token base64 encoded
  • Every URL derives from base_url: /api/v1/fill/{id}.pdf, /api/v1/generate-pdf, /api/document-group/{eid}.zip and /graphql
  • The first response's X-RateLimit-Limit and X-RateLimit-Interval-Ms set the limits; concurrent requests wait for that first response; no more than the limit is sent in any interval
  • A 429 is retried after Retry-After plus 50ms, and retried again if the retry is also a 429
  • A GraphQL error in a 200 response raises AnvilRequestError with status_code == 200, the errors list and the partial data
  • A REST validation error raises with its fields; a non-JSON 502 raises with the status text; an empty JSON body raises a JSONDecodeError error
  • A file from prepare_graphql_file is sent as operations, map and a file part with the right filename and mimetype; a base64 dict is sent as plain JSON
  • A rate-limited upload from a path is sent in full again; a non-seekable file object fails with an error rather than sending an empty file
  • fill_pdf(..., data_type="stream") returns a requests.Response that streams the PDF
  • generate_etch_signing_url returns the URL string; get_etch_packet the packet; remove_weld_data a bool
  • A first response without rate limit headers (e.g. a proxy 502) does not fix the client at 1 request/s: the next response with headers sets the limits
  • A 2xx JSON response with a top-level name key is returned as data, and request_rest("GET", "https://other.host/...") raises a ValueError
  • request_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 empty
  • A Retry-After of 1e9 on a 503 waits 60 seconds, not 31 years; nan falls back to backoff
  • A 503 is retried with backoff and the eventual 200 returned; a 4xx is not retried; max_retries=0 sends once; a retried upload is sent in full again
  • Two clients with the same key and base_url share one rate limiter and the limits the first response taught; clients for different keys do not
  • create_etch_packet({"name": ..., "webhookURL": ..., "mergePDFs": True}) and forge_submit({"forgeEid": ..., "payload": {}}) accept the camelCase keys the docstrings promised
  • Anvil(key, "prod") works with a DeprecationWarning, and forge_submit({"forge_eid": ..., "payload": {}, "webhook_url": "https://..."}) sends the URL

Breaking Changes

A major release (6.0.0). The constructor, return values, error type, request_rest signature and removed classes are listed under Unreleased in CHANGELOG.md.


Open workspace in Conductor

@fostimus
fostimus added this pull request to stack #101 October 21, 2026 21:04
@fostimus fostimus changed the title Replace the gql and ratelimit transports with one requests-based client [Feature] Modernization: one requests transport with base_url, OAuth, adaptive rate limits and typed errors Oct 21, 2026
@fostimus
fostimus force-pushed the df/modernize-transport branch from 1f5b51f to 5bf0921 Compare October 21, 2026 22:18
@fostimus
fostimus force-pushed the df/modernize-transport branch from 5bf0921 to 3f9c1a1 Compare October 27, 2026 22:08
@blacksmith-sh

This comment has been minimized.

@fostimus
fostimus force-pushed the df/modernize-transport branch from 4549917 to 7e235a4 Compare October 28, 2026 17:46
Base automatically changed from df/modernize-tooling to master October 28, 2026 17:52
fostimus and others added 6 commits October 28, 2026 10:52
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
fostimus force-pushed the df/modernize-transport branch from 7e235a4 to 8d20ae8 Compare October 28, 2026 17:52
@fostimus
fostimus marked this pull request as ready for review October 28, 2026 17:52
@fostimus
fostimus merged commit e194cbf into master Oct 28, 2026
7 checks passed
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