Skip to content

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

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

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

Conversation

@chandrasekharan-zipstack

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

Copy link
Copy Markdown
Collaborator

Stacked on #34 — this PR targets LW-406-deprecate-misspelled-params, not main. #34 must merge first. The compat baseline is pinned to #34's head (0e9fda3), so that branch is what "parity" means here.

What

LLMWhispererClientV2 builds its requests from a transport generated off the API's OpenAPI spec instead of assembling them by hand, and sends them over httpx.

Unchanged and deliberately untouched: the retry policy and its wait strategy, the wall-clock deadline handling, the wait_for_completion poll loop, the deprecated-parameter resolver, the exception hierarchy, and every return shape. Only the innermost transport call moved.

  • specs/llmwhisperer.json + tools/gen_sdk.sh regenerate src/unstract/llmwhisperer/sdk_llmwhisperer/ with a pinned generator. The tree is committed, marked linguist-generated, stamped DO-NOT-EDIT, and excluded from ruff, docformatter, mypy and pre-commit — regeneration overwrites it wholesale, so a fix applied there is lost on the next run.
  • Only the generated _get_kwargs builders are used. Responses are read as raw JSON exactly as before, so no generated response model sits on any code path.

Three things a naive swap breaks

  • Exception types. Callers catch requests.ConnectionError and requests.Timeout by name; the httpx classes are not subclasses. They are translated at the seam, inside the retried call — the retry predicate matches on those same types, so translating around the retry loop would silently disable transport-error retry. requests.ConnectTimeout is both a ConnectionError and a Timeout, so a connect timeout maps to it rather than to a plain Timeout.
  • Redirects. The previous transport followed them by default; httpx does not. Without follow_redirects a 30x from a proxy or an http→https upgrade surfaces as API error: empty response body.
  • Injected defaults. The generated builders write every spec-declared parameter. Requests carry only what the client set — sending a default pins a value the service would otherwise choose. url_in_post exists only in URL mode, and the URL travels in the body, not also on the query string.

Query values are rendered the way the previous transport rendered them: httpx serialises booleans as true/false where the old one sent True/False.

Remaining differences, all wire-irrelevant

  • Query-parameter order is alphabetical rather than insertion order.
  • The webhook JSON body uses compact separators and a different key order; same object.
  • User-Agent is now python-httpx/....

Testing

tests/unit/compat_test.py compares this client against the baseline vendored at tests/baseline/client_v2_pr34.py (refreshed via tools/refresh_baseline.sh), running both over the same responses:

  • the outgoing request — method, path, query and body — for all 14 call shapes, including every whisper parameter at once and all three input modes
  • the returned value across 6 status codes, and error handling across 5 body shapes including empty and non-JSON, so the published client's own rough edges are preserved rather than quietly improved
  • the wait_for_completion poll loop end to end
  • constructor parameters, defaults and order, all 11 public signatures, class attributes, and the deprecated-parameter resolver and get_highlight_rect compared statement by statement
  • retry, deadline capping and deadline-stops-retries at the new seam; exception translation across 9 httpx classes
  • wrapper coverage over the spec, with the 9 operations neither client exposes listed explicitly

234 unit tests pass. The existing unit suite is unchanged apart from its patch target.

Live round trip

Both clients — this one and the released one vendored under its own module name — were run
against the real staging service over the same seven call shapes, with every request
recorded at the transport layer: usage, a garbage hash sent to status/retrieve/detail, a
bad API key, a synchronous extract, and an asynchronous extract followed by a status poll
and a retrieve. Each client submits its own document, since retrieve is one-shot.

Wire output was identical on every call in both upload modes. Return values matched except
for what the service varies between two runs of the same document: per-run timings,
confidence_metadata, and font_info character metrics.

The run found one divergence the offline suite could not see. httpx.ReadTimeout fell into
the TimeoutException catch-all and surfaced as requests.Timeout, where the released
client raises requests.ReadTimeout — so a caller catching ReadTimeout by name would
have stopped matching. pytest.raises is subclass-tolerant, so the translation-table test
passed either way; it now asserts the exact class and fails on the previous code.

Note: pre-commit does not pass on this branch — ruff (ANN401 on the pre-existing -> Any signatures), mypy (missing types-requests) and docformatter (on tests/integration) all fail identically on main, so this commit was made with --no-verify. Worth fixing separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ

Added after review started

02485e1 adds the six extraction parameters the service accepts and this client had no argument for: allow_rotated_text, watermark_angle_threshold, ignore_vertical_text, derotate_threshold, checkbox_confidence_threshold and min_table_width. It is a separate commit so it reads as a delta rather than a rewrite.

They are keyword-only, named exactly as the service names them, and each defaults to unset. An unset parameter is not sent, so the query string is byte-for-byte unchanged for every existing call shape and the service still picks its own default — the same send-only mechanism the rest of this PR uses, with the six names added to its declared set. url_in_post is deliberately not among them: in URL mode the URL travels in the body, and whether to say so is this client's decision rather than a caller's.

The signature-parity test now exempts keyword-only parameters, since none is reachable from a released call shape; the private deprecated-parameter resolver is still compared including its own keyword-only argument. New tests assert that an unrequested parameter is absent from the wire and that each one, given a falsy or off value, still reaches it — a truthiness filter would drop those and hand the decision back to the service silently.

The client now builds its requests from a transport generated off the API's
OpenAPI spec instead of assembling them by hand, and sends them over httpx.
The retry policy, the deadline handling, the poll loop, the deprecated-parameter
resolver and every return shape are unchanged; only the innermost transport call
was swapped.

Three things a naive swap would have broken, and what keeps them working:

- Callers catch requests.ConnectionError and requests.Timeout by name. The httpx
  equivalents are not subclasses, so they are translated at the seam — inside
  the retried call, because the retry predicate matches on those same types.
  requests.ConnectTimeout is both a ConnectionError and a Timeout, so a connect
  timeout maps to it rather than to a plain Timeout.
- The previous transport followed redirects; httpx does not by default. Without
  it a 30x from a proxy surfaces as "API error: empty response body".
- The generated builders write every spec-declared parameter. Requests carry
  only what the client actually set: sending a default pins a value the service
  would otherwise choose. url_in_post exists only in URL mode, and the URL
  itself travels in the body, not also on the query string.

Query values are rendered the way the previous transport rendered them, since
httpx lowercases booleans.

The generated tree is committed but never hand-edited — tools/gen_sdk.sh
overwrites it wholesale from specs/llmwhisperer.json with a pinned generator, so
fixes belong in client_v2.py or in the spec. It is marked linguist-generated and
excluded from lint, formatting and type checking for the same reason.

Testing: tests/unit/compat_test.py compares this client against the vendored
baseline at tests/baseline — the request that goes out for all 14 call shapes,
the value returned across 6 status codes and 5 error bodies, the poll loop, the
constructor and public signatures by AST, the retry and deadline behaviour, and
exception translation. 234 unit tests pass. A live round trip is still
outstanding.

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
The service takes six OCR parameters this client has no argument for --
allow_rotated_text, watermark_angle_threshold, ignore_vertical_text,
derotate_threshold, checkbox_confidence_threshold and min_table_width -- so a
caller who needs one cannot reach it at all.

They are added as keyword-only arguments named exactly as the service names
them. Each defaults to unset and an unset parameter is not sent, so the service
still picks its own default and the query string is unchanged for every
existing call shape. url_in_post stays out: in URL mode the URL travels in the
body, and whether to say so is this client's decision, not a caller's.

The signature-parity test now exempts keyword-only parameters, since none is
reachable from a released call shape.
Base automatically changed from LW-406-deprecate-misspelled-params to main August 12, 2026 09:14
chandrasekharan-zipstack and others added 12 commits August 12, 2026 20:43
The spec now carries what the walk could not infer: which parameters are
required, the closed sets the service validates against, the error body it
returns, and the binary media types three endpoints answer with.

Two of those broke generation quietly. A response whose content type the
generator does not recognise is dropped with a warning; so is an entire
endpoint whose parameter default its own enum forbids -- and the run still
exits 0, so the client came out missing the extraction endpoint with every
gate green. The generator's output is now checked for warnings before
anything is written, and the three binary content types are mapped to the
one it understands rather than being softened in the spec.

The unwrapped-operation list is checked against the spec before being
subtracted from it: an entry excusing an operation the spec no longer
declares would otherwise keep passing forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The parameter was renamed server-side, and a service older than v2.64.2
reads only the previous spelling: the separator silently falls back to the
default instead of failing, which is the kind of thing a caller finds in
the output rather than in an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
Two unrelated drifts under the same seam.

The published client asked for no compression -- `Accept-Encoding: identity`,
added by the layer below `requests`, not by any code here -- and httpx asks
for gzip. A service response this client has never decoded is not something
a transport swap should start requesting; `custom_headers` still overrides.

Three httpx failures also 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 mapped to a class the
published client never raised for them, since requests had no write or pool
timeout. The class decides retries too, so an unsendable URL now stops
instead of being attempted four more times.

Headers are compared over a real socket, because the transport adds them
below anything the client can be asked for. The list of failures is now a
walk of httpx's own exception tree rather than a list that stops growing.

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
The baseline was a pre-release commit 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. It is now taken from the published wheel — what callers actually
have installed — and pinned by a digest that no edit can restate.
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.
A query string carries no null, so a caller passing None got the literal
string "None" sent as the value. These are overrides the service defaults
when absent, and absent is what None asks for.
They did not, and had not for some time. Three things were in the way:

- ruff and docformatter disagreed about where a multi-line docstring's
  closing quotes belong, so each run flipped every docstring back and
  pre-commit could never converge. D209 is now off; docformatter decides.
- the pinned hook ran ruff 0.3.4 while the dev group installed 0.11.9, and
  the two disagree on import order. Both are pinned to one version now.
- mypy could not read `requests` or `pkg_resources` without their stubs, so
  it reported the imports as errors and checked nothing that used them.

The transport-failure translation became a table because the chain of
`except` clauses had grown past the complexity limit; the branches, their
order and their reasons are unchanged. `Any` is left alone where it is the
honest annotation for a service that takes and returns arbitrary JSON.
The spec advertised one region-neutral URL that does not resolve; it now lists
the two regions that serve the API. Documentation only -- the generated SDK
takes its base URL from the caller, and regenerating against this spec produces
no change.
The committed spec covers the whole service while the client wraps part of it,
and nothing said so: a reader comparing the two had no way to tell a deliberate
omission from a gap. Point at the list the tests already enforce rather than
restating it here, where it would go stale.
A comment that describes what the code used to do stops being checkable once
that state is gone.
The formatter exclusions were global, so detect-private-key and gitleaks
skipped the generated tree and the vendored baseline. They are per hook now,
on the hooks whose fix would be lost on the next refresh.

InvalidURL is one of the three httpx families outside RequestError; requests
raised its own, so it is translated. The docstring names the other two as
propagating. The drift gate also sees a newly created file now.

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