Skip to content

refactor(client): issue requests through a generated transport - #27

Draft
chandrasekharan-zipstack wants to merge 26 commits into
mainfrom
feat/generated-transport
Draft

refactor(client): issue requests through a generated transport#27
chandrasekharan-zipstack wants to merge 26 commits into
mainfrom
feat/generated-transport

Conversation

@chandrasekharan-zipstack

@chandrasekharan-zipstack chandrasekharan-zipstack commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

APIDeploymentsClient now issues its requests through a transport generated from the API's OpenAPI spec, instead of hand-built requests calls.

  • specs/docstudio-oss.json (generated by the backend that serves these endpoints) + tools/gen_sdk.sh regenerate src/unstract/api_deployments/sdk_docstudio/ with a pinned generator. The generated tree is committed, marked linguist-generated, stamped DO-NOT-EDIT, and excluded from lint — regeneration overwrites it wholesale, so fixes belong in client.py or in the spec.
  • The retry policy, its wait strategy, and every return shape are unchanged. Only the innermost transport call was swapped.
  • httpx transport failures are translated to their requests equivalents inside the retried callable, so callers that catch ConnectionError/Timeout still work and transport-error retry still counts.
  • Requests carry only the fields this client sets. The generated builders write every spec default; sending a default pins a value the server would otherwise choose.

Behaviour changes

Two, both deliberate:

  • File handles opened by structure_file are now closed after the request. The previous client leaked them.
  • The status URL is rebuilt from the spec route plus the execution id rather than concatenating the server-supplied path. test_status_url_matches_the_released_client pins that the resulting request is identical.

Testing

tests/test_compat.py compares this client against 1.5.3, vendored at tests/baseline/client_1_5_3.py and refreshed via tools/refresh_baseline.sh:

  • constructor parameters, defaults, order, public method signatures and class attributes, read out of the released source by AST
  • what goes out on the wire: send-only guards, multipart values, URLs, and that api_timeout (a backend execution mode, not a socket timeout) never reaches the transport
  • httpx → requests exception translation, asserted on the exact class (type(caught.value) is expected), not with a bare pytest.raises — that accepts a superclass, and it hid two real mismatches: a connect timeout arriving as a plain Timeout, and a read timeout doing the same
  • the exact dict each method returns, by running both clients over the same responses across 14 status/body cases

324 tests pass. The existing 745-line retry suite is unchanged apart from its patch target.

A live round trip against a deployment is still outstanding.

Added after review started

c291e36 adds the deployment's remaining request parameters to structure_file, as a separate commit so it reads as a delta rather than a rewrite.

The deployment accepts twelve request parameters. The client could send two, and only through the constructor — the other ten had no argument to travel through. They are now keyword-only arguments named exactly as the API names them, each defaulting to unset, and an unset parameter is not sent. So the request is byte-for-byte unchanged for every existing call shape, and the server still picks its own defaults. timeout and include_metadata fall back to the constructor values; a timeout passed per request selects the execution mode for that request.

The signature-parity test now exempts keyword-only parameters, since none of them is reachable from a released call. New tests cover the spec-exact names, keyword-only enforcement, that falsy values (0, False, "") are sent rather than filtered out as absent, and that a per-request timeout moves the sync/async retry decision with it.

ed89066 does the same for check_execution_status, which could send include_metadata and only from the constructor: it gains include_metadata, include_metrics and include_extracted_text as keyword-only arguments, unset by default and therefore not sent, so the query string is unchanged for every existing call. execution_id stays out — it is read from the endpoint URL the server handed back.

🤖 Generated with Claude Code

https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ

The HTTP layer is now generated from the committed OpenAPI spec rather
than hand-written, so URLs, query names and multipart encoding follow the
spec instead of being restated here. tools/gen_sdk.sh regenerates it with
a pinned generator; the tree is committed but never hand-edited.

The public surface is unchanged on purpose: same constructor, same return
dicts, same exceptions. What was deliberately kept rather than rewritten:

- The retry policy, verbatim. Attempt counts, Retry-After on 429,
  exponential jitter, file rewinding, and the sync/async POST distinction
  are the contract, and nothing about the transport should restate them.
- Transport failures are translated to their `requests` equivalents
  inside the retried call, not around it, so the retry policy still sees
  the exception types it is configured to retry. `requests` stays a
  dependency for those classes because callers catch them by name.
- Response fields are read from the JSON body, never from a generated
  response model: a model exists only for the statuses the spec declares,
  and error bodies are typed too loosely to read.

Only the parameters this client sets are sent. The generated builders
write every declared default into a request, and sending a default is not
the same as omitting it — it pins a value the server would otherwise
choose, and the two diverge as soon as the server's default changes.

No transport timeout is configured, as before: api_timeout selects a
backend execution mode and is not a socket timeout.
The transport changed; the published behaviour must not. These tests compare
against the 1.5.3 client vendored under tests/baseline: constructor and method
signatures via AST, the request that goes out, the exceptions that come back,
and the exact dict each method returns — the last by running both clients over
the same responses.

Also stop sending the generated fixed multipart boundary. An uploaded file
containing those bytes would corrupt the encoding, so the header is dropped and
the transport picks a random boundary, as the previous client did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
requests.ConnectTimeout is both a ConnectionError and a Timeout. Mapping
httpx.ConnectTimeout to a plain Timeout — which is all httpx's own hierarchy
implies — stops every caller that catches the connection family from catching a
connect timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
httpx.ReadTimeout was landing in the TimeoutException catch-all and coming
back out as requests.Timeout. Callers that catch requests.ReadTimeout by name
stopped matching. The translation table test used pytest.raises, which is
subclass-tolerant and passed either way; it now asserts the exact class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
@chandrasekharan-zipstack

Copy link
Copy Markdown
Contributor Author

Backported a fix found by the sibling client's live run: httpx.ReadTimeout was falling into the TimeoutException catch-all and surfacing as requests.Timeout, where the published client raises requests.ReadTimeout. A caller catching ReadTimeout by name would have stopped matching. The translation-table test passed either way because pytest.raises is subclass-tolerant; it now asserts the exact class and fails on the previous code. 310 tests pass.

The live round trip for this client is still outstanding — it needs staging credentials.

chandrasekharan-zipstack and others added 21 commits August 12, 2026 13:12
The spec is now produced and committed by the backend that serves these
endpoints, so this repo tracks that file instead of a copy maintained
elsewhere. Regenerating picks up its root `tags` array; the generated tree is
otherwise unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
…_file

The deployment accepts twelve request parameters; the client could only send
two, and only by way of the constructor. The rest had no argument to travel
through, so callers that need a tag, an LLM profile or a HITL queue cannot
reach them at all.

They are added as keyword-only arguments named exactly as the API names them.
Every one defaults to unset and an unset parameter is not sent, so the server
still picks its own default and the request is byte-for-byte unchanged for
every existing call shape. `timeout` and `include_metadata` fall back to the
constructor values when not passed, and a `timeout` passed per request selects
the execution mode for that request.
The status endpoint takes include_metadata, include_metrics and
include_extracted_text; the client could send only the first, and only via the
constructor, so a caller wanting metrics on one poll had nowhere to ask.

They are added as keyword-only arguments named exactly as the API names them,
each defaulting to unset. An unset parameter is not sent, so the query string
is unchanged for every existing call shape and the server still picks its own
default. execution_id stays out: it is read from the endpoint URL the server
handed back.
The backend's spec now declares the deployment key as a bearer scheme on
each operation, describes the error statuses a caller has to branch on, and
no longer publishes the MCP endpoints or a request field the deployment
does not accept.

With no operation left outside the facade, the coverage check compares the
declared set whole. Excusing an operation by name kept passing after the
spec stopped declaring it, and a green run said nothing about whether the
exception still described anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
A schema it cannot parse is downgraded to a warning: the endpoint or
response it belongs to is dropped, the rest is written, and the run exits
0. Nothing downstream can tell that from a client that never had the
operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
httpx renders a bool as `true`; urlencoding a Python bool gives `True`,
which is what went out before. The service reads both, so nothing breaks
either way -- but a caller diffing traffic across the upgrade should see
no change, and this is the only field that moved.

The parity test could not see it: it stringified our parameters before
comparing them with the published ones, which turned `True` into `True`
on both sides. It now compares what the transport will actually send.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
Rebuilding the URL from the spec's path template dropped any prefix the
deployment is served under -- an ingress route, an on-prem reverse proxy --
because no route template can carry one. The released client posted to the
URL verbatim.

The parity test could not see this: it compared against a deployment URL
with no prefix, so both sides agreed. It now runs over a prefixed URL, a
slash-less one and a mixed-case one, and compares against the released
client's URL rather than a constant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
Three httpx failures reached callers as httpx classes, which nothing
downstream catches: a redirect loop, an undecodable body, and any future
RequestError that is not a TransportError. Two more were translated to a
class the released client never raised for them -- requests had no write or
pool timeout, and both surfaced as ConnectionError.

The class chosen here also decides what gets retried, so an unsendable URL
is now MissingSchema rather than a ConnectionError the retry loop would
attempt four more times.

The parametrised list of failures is replaced by a walk of httpx's own
exception tree: a hand-written list is exactly as complete as the day it
was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The transport adds headers no client object holds, so the only place the
two can be compared is a socket. Both clients now run against a loopback
server and their request heads are diffed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The generated tree is committed, so an edit inside it reviews like any other
change and then vanishes on the next regeneration -- as does a spec change
nobody ran the generator over. Regenerating in CI and diffing is what
notices either one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
Nothing bounds a stalled connection: the transport is untimed, and
api_timeout cannot serve as one because the backend reads it as an execution
mode -- 0 selects async, and negative values are accepted. A run that
stalled for roughly 985 seconds is what this is for.

Keyword-only and unset by default, so no released call shape changes and the
default behaviour stays exactly what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The released client concatenated its base URL with whatever the server
handed back, so only a root-relative endpoint worked -- an absolute one
became `https://hosthttps://host/...`. Reading the execution id out and
rebuilding the route from the spec means all three spellings resolve to the
same request, and this is what says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The baseline was pinned by a version string in its own header comment, which
an edit to the file can rewrite as easily as the code below it. Every parity
test compares against this file, so a weakened baseline weakens all of them
silently.
The generated transport is written against one httpx minor series; an upgrade
needs a regeneration and a test run, not a resolver decision taken at install
time in someone else's environment.
…params

A multipart form field carries no null, so a caller passing None got the
literal string "None" sent as a tag, an LLM profile id or a queue name for
the service to resolve. These are overrides the service defaults when absent,
and absent is what None asks for.
Each of these stated what the line below it does, or described a prior state
that is no longer there to check against. Keep the reason, drop the narration.
The status URL was built from scheme and host alone, so a deployment served
under a path prefix could execute -- the execute call sends the caller's URL
verbatim -- and then never poll, losing the result of a paid execution.
Documented divergence: the previous release has the same gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The CLI that owns the name depends on this package, so the two always share
an environment and the entry point collides on every install. `python -m
unstract.clone` is unchanged, and the CLI offers the same command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
A 3xx was returned as if it were the answer, which a poll loop reads as a
finished execution with no status; the previous transport followed redirects on
both verbs. A status endpoint carrying no execution id now fails instead of
polling for a blank one. InvalidURL joins the translation table, and the
docstring names the two httpx families that stay outside it.

Adds the multi-file upload comparison the parity suite never had, and lets the
drift gate see a file the generator newly creates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The status endpoint is the service's instruction for reaching one execution.
Only its execution_id was being read: any other parameter on it -- a region
hint, a signature -- was dropped from every poll, and a deployment URL that
does not carry the spec route was polled at a path rebuilt from that route
rather than at the endpoint itself.

Both end the same way, at a paid execution whose result is never collected.
Remaining parameters are now forwarded, and where no path prefix can be
derived the endpoint is used as it came.

Also pins the exception classes a malformed api_url raises. They differ from
the released client's for two inputs; the divergence is deliberate and the
test says so.
MissingSchema is a ValueError, so the row that exists to record released
parity could not tell the two apart; it asserts the exact class now.

The README and a release-notes draft carry the differences a caller can
observe, including the console script this branch removed.
It shells out to ruff for post-processing. Finding none, it warns and
exits 0, and the warning gate reports that as a spec it could not parse
-- a clean regeneration on a runner without a global ruff failed with a
message pointing at the wrong thing entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
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