Skip to content

refactor: fold the platform client into a shared facade base - #30

Merged
chandrasekharan-zipstack merged 1 commit into
feat/whoami-and-deployment-listingfrom
feat/platform-client-hardening
Sep 10, 2026
Merged

chandrasekharan-zipstack merged 1 commit into
feat/whoami-and-deployment-listingfrom
feat/platform-client-hardening

Conversation

@chandrasekharan-zipstack

@chandrasekharan-zipstack chandrasekharan-zipstack commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #29. Review that one first; this PR's diff is only what came out of reviewing it.

Why

#29 added PlatformAPIClient as a second, independent client. The pooled transport, the close/reopen handling and the httpx-to-requests exception translation were copied out of APIDeploymentsClient, and the copy silently dropped the retry policy. Two copies of transport code drift, and only one of them was getting fixes.

Both clients now inherit a _HttpxFacade base that owns the transport, the retry-with-Retry-After policy and the exception translation. A subclass supplies its error class and its methods. ~184 duplicated lines go away, and the platform operations get the retry behaviour the README already promised them.

Defects fixed

  • list_deployments sent workflow=None on every unfiltered call. The generated builder renders that one parameter with str() before it filters None out, so the literal string "None" went on the wire as a filter matching no workflow. Unset filters are now omitted rather than passed as None, which also holds if the generator special-cases another parameter later. (Caught by Greptile — it was mine, introduced when I widened the signature to | None = None.)
  • The pool was built outside the lock. AuthenticatedClient.get_httpx_client() builds lazily and unsynchronised, so publishing the client to self before warming it let two racing threads build two pools. It is now warmed inside the lock. (The docstring claiming httpx made this safe was wrong.)
  • A close during flight escaped untranslated. httpx answers a send on a closed client with a bare RuntimeError, which is not in the subtree _translate_transport_errors covers — so moving the resolve inside that region, as this PR first did, fixed nothing. It is now translated at the send, as a ConnectionError. (Also Greptile; also mine.)

Exceptions

APIDeploymentsClientException (which predates #29 — it is in the repo's first commit) never worked. Its __init__ nested three more defs that were never bound to the class, so the message was dropped, Exception.__init__ was never called, str(e) was empty and the error_message() the docstring advertised did not exist.

It is now an alias of a new UnstractError base, with APIDeploymentError and PlatformClientError beneath it. The alias points at the base, not a leaf, so except APIDeploymentsClientException keeps catching everything either client raises, including anything added later. .value and error_message() are deliberately not reimplemented — they never worked, so nothing can depend on them.

Also here

  • The generated models are re-exported. WhoAmIResponse, APIDeploymentSummary and PaginatedAPIDeploymentSummaryList were generated and then unreachable. Callers who want typing can now WhoAmIResponse.from_dict(...); facade methods keep returning dict[str, Any]. No hand-written mirror of the spec, because regeneration would not update one.
  • The platform client gets its own logger. Both shared the module logger, so logging_level on one re-levelled a live instance of the other, turning its debug output — which includes response bodies — on or off as a side effect.
  • Bodies are validated. A 2xx that is unreadable, or JSON that is not an object, now raises an error naming the status, content-type and a bounded excerpt, instead of an AttributeError from .get() several frames later. The ERROR log for it is bounded to the same excerpt the exception carries.
  • Inputs are checked at the boundary. An org_id that is empty or blank is refused before the request instead of producing a // or %20%20 path; a path component on base_url is warned about rather than silently thrown away by urljoin.
  • SKILL.md step 5 gains the recipe for adding an operation: which class it belongs to, the method shape, and why it builds from _get_kwargs rather than sync_detailed.

Things reviewers should push back on if they disagree

  • _get_kwargs is still used, deliberately. Routing through the public sync_detailed looks cleaner but reintroduces the bug feat: pick up the whoami and deployment-listing operations #29 fixed: the generated _parse_response calls PlatformKeyError.from_dict(response.json()) unguarded on a declared error status, so a gateway answering 401 with HTML raises JSONDecodeError before the facade sees the status. The private coupling is pinned by a test, so a generator bump fails in CI rather than at a customer.
  • close() still lets the next call rebuild the pool. That is pinned by an existing test and stated in the docstring, so it is behaviour, not a leak.
  • type(e).__name__ changesAPIDeploymentError where it was APIDeploymentsClientException. Nothing in the tree or on PyPI matches on that, and except is unaffected.

Testing

458 passing. New coverage: the _get_kwargs contract, an unfiltered listing sending no filters, close-during-send, header propagation over a real httpx.MockTransport, close-then-reuse, single-build-under-contention, retry on a retryable status, transport settings propagation, non-object JSON, unreadable 2xx bodies (and the bound on their log), empty and blank org_id, the base_url path warning, the package-root re-exports, alias catchability, message preservation, and logger isolation.

Every fix was mutation-tested — the fix reverted in client.py, the suite re-run, and the failing test confirmed. Nine mutations, nine caught. The logger-isolation regression initially passed unnoticed, which is why that test exists.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CnhmFYFRBrK376tZyDtwUM

@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with all previous findings resolved and no new actionable defects identified.

Summary

  • Adds the public PlatformKeyClient surface and generated response-model exports.
  • Introduces a common exception hierarchy while preserving the legacy catch-all alias.
  • Fixes omitted-filter handling, concurrent pool initialization, close-during-send translation, bounded error logging, and organization-ID validation.
  • Documents platform-key usage and the shared error contract.
  • Expands compatibility and regression coverage for the refactored behavior.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    Caller[Caller] --> Deployment[APIDeploymentsClient]
    Caller --> Platform[PlatformKeyClient]
    Deployment --> Facade[_HttpxFacade]
    Platform --> Facade
    Facade --> Pool[Authenticated httpx pool]
    Facade --> Retry[Retry and Retry-After policy]
    Facade --> Translation[requests-compatible exception translation]
    Deployment --> DeploymentAPI[Deployment execution API]
    Platform --> PlatformAPI[Platform identity and listing API]
Loading

Reviews (3) · Last reviewed commit: "refactor: fold the platform client into ..."

Comment thread src/unstract/api_deployments/client.py Outdated
Comment thread src/unstract/api_deployments/client.py
Comment thread src/unstract/api_deployments/client.py
Comment thread src/unstract/api_deployments/client.py Outdated
Comment thread README.md
PR #29 gave the platform client its own copy of the transport: the pool,
the close/reopen handling and the httpx-to-requests exception translation
were duplicated from APIDeploymentsClient, and the copy had no retry policy
at all. Two copies of that code drift; only one of them was getting fixes.

Both clients now inherit `_HttpxFacade`, which owns the lazily built pooled
transport, `close`/context-manager support, the exception translation and the
retry-with-Retry-After policy. A client subclass supplies its error class and
its methods, nothing else. That drops ~184 duplicated lines and gives the
platform operations the retry behaviour the README already promised.

Three defects fall out of sharing the code:

- The transport pool is now built inside the lock. `AuthenticatedClient
  .get_httpx_client()` builds lazily and unsynchronised, so publishing the
  client before warming it let two threads build two pools.
- A close during flight no longer escapes untranslated. httpx answers a send
  on a closed client with a bare `RuntimeError`, which is not in the subtree
  `_translate_transport_errors` covers, so it reached callers catching the
  documented `requests` types. It is translated at the send.
- `list_deployments` no longer sends `workflow=None`. The generated builder
  renders that parameter with `str()` before it filters `None` out, so the
  literal string "None" went on the wire as a filter matching no workflow on
  every otherwise unfiltered call. Unset filters are omitted instead, which
  also holds if the generator special-cases another parameter later.

Exceptions get a hierarchy. `APIDeploymentsClientException` never worked --
its `__init__` nested three more `def`s that were never bound to the class,
so `message` was dropped and `Exception.__init__` was never called, leaving
`str(e)` empty and the documented `error_message()` non-existent. It is now
an alias of a new `UnstractError` base, with `APIDeploymentError` and
`PlatformClientError` beneath it. Catching the old name still catches both
clients, including anything added later.

Also here:

- The generated models are re-exported, so callers who want typing can
  `WhoAmIResponse.from_dict(...)` instead of us hand-writing a mirror of the
  spec that regeneration would not update. Facade methods keep returning
  `dict[str, Any]`.
- The platform client gets its own logger. Both clients shared the module
  logger, so levelling one re-levelled the other, switching a live sibling's
  debug output -- which includes response bodies -- on or off as a side effect.
- A 2xx body that is unreadable, or JSON that is not an object, is now an
  error naming what arrived rather than an `AttributeError` downstream. The
  ERROR log for it is bounded to the same excerpt the exception carries.
- An `org_id` that is empty or blank is refused before the request, and a path
  on `base_url` is warned about rather than silently discarded by `urljoin`.
- `.claude/skills/spec-upgrade/SKILL.md` step 5 gains the recipe for adding an
  operation: which class it belongs to, the method shape, and why it builds
  from `_get_kwargs` rather than `sync_detailed`.

No runtime breaking change. The one visible shift is `type(e).__name__`, which
becomes `APIDeploymentError` where it was `APIDeploymentsClientException`;
`except APIDeploymentsClientException` is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnhmFYFRBrK376tZyDtwUM
@chandrasekharan-zipstack
chandrasekharan-zipstack merged commit 8768db3 into feat/whoami-and-deployment-listing Sep 10, 2026
4 checks passed
@chandrasekharan-zipstack
chandrasekharan-zipstack deleted the feat/platform-client-hardening branch September 10, 2026 12:42
chandrasekharan-zipstack added a commit that referenced this pull request Sep 10, 2026
* feat: pick up the whoami and deployment-listing operations

Runs the `spec-upgrade` pipeline against Zipstack/unstract main at
520b98d7a, which merged the two operations the CLI needs (unstract#2269 and
unstract#2278).

The spec is copied byte-for-byte and `SPEC_SOURCE` moves with it in this same
commit — revision and sha256 both — so a current copy stays distinguishable
from one the backend has moved past. I verified the *previous* record before
moving it: the vendored file matched its recorded sha256 and was byte-identical
to the backend at `eddd4b746`, so this upgrade starts from an honest baseline.

Regeneration is purely additive: new `api/identity/whoami.py` and
`api/deployment/list_deployments.py`, six new models, and prose-only changes to
`execute`/`status` where #2278 reworded the descriptions. No operation, field or
model was removed, so this is a **minor** bump rather than a major one. The
generator exited clean, and regenerating a second time produces byte-identical
output, so `sdk-drift` will pass.

**The new facade class.** `whoami` and `list_deployments` both authenticate with
a platform key, and neither fits `APIDeploymentsClient`: that class takes a
*deployment* URL and derives an organisation and API name from its last two
segments, which `whoami` has neither of. So `PlatformAPIClient` sits alongside
it, sharing the generated transport — both schemes are HTTP bearer, only the
token differs — and raising the same exception type. Folding them together would
have meant a class whose required `api_url` is meaningless for half its methods.

Without this the operations are generated but unreachable, and `unstract-cli`
keeps reaching into `unstract.clone.PlatformClient` — the org-cloning tool's
hand-written admin client — which is how the CLI drifted off the generated
surface to begin with.

**A defect the new tests caught.** `_error_text` fell back to `response.text`,
but the generated `Response` is an attrs wrapper carrying `.content`, and
`parsed` is a model instance rather than a mapping. A 401 through the new client
would have raised `AttributeError` while trying to report the refusal. Both
halves are fixed; the `_error_text` change is additive, so httpx callers are
unaffected.

**Test coverage.** `test_every_declared_operation_is_wrapped` fired exactly as
designed when the spec grew. Extending `WRAPPED_OPERATIONS` was not the right
answer, though: `whoami` declares no `ErrorResponse`, so that suite's "both
families are in play" assertion is false for it by construction, and
`_declared_responses` indexes `content` unconditionally, which its bodyless 500
would `KeyError` on. A parallel `PLATFORM_OPERATIONS` manifest carries its own
status pins and error-reporting coverage, and the whole-set comparison now
unions the two — so an operation belonging to neither still fails there, which
is the property that test exists for.

`__version__` and the compat baseline are deliberately untouched: the release
workflow reads the former as the last released version and applies the bump at
dispatch, and a spec upgrade is not a reason to move the parity reference point.

430 tests pass, up from 419. `ruff check` and `format --check` clean on
everything this touches.

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

* fix: give PlatformAPIClient the contract APIDeploymentsClient already has

Six findings from the review on this PR. Five were real contract gaps and one
was a missed export; all are pinned by tests that fail when the fix is reverted.

**The body is read as JSON, not through the generated model.** This is the one
that mattered. `sync_detailed` reaches `_parse_response`, which does
`PlatformKeyError.from_dict(response.json())` on a 401 with no guard: a gateway
answering 401 with HTML raises `JSONDecodeError`, and a DRF-shaped
`{"detail": ...}` raises `KeyError: 'message'` -- both out of the generated
parser, before this facade sees the response. So a rejected key crashed instead
of being reported. The request is now issued from the generated `_get_kwargs`
and the body read through `APIDeploymentsClient._read_body`, which is exactly
why that helper exists.

This is the same defect class as the `_error_text` fix in the previous commit. I
fixed the half where reporting a refusal crashed and missed the half where
building the model crashed first.

**Transport failures are translated.** The class called `sync_detailed`
directly, so an unreachable host raised raw `httpx.ConnectError` -- contradicting
the module docstring this PR added, which promises the `requests` exception types
callers catch. It now goes through `_send`, like every deployment-key request.

**The credential is read per request.** `AuthenticatedClient` bakes its auth
header on first use, so a key assigned after the transport was built kept
sending the old one. `_send` sets the header per call.

**`close`, `__enter__` and `__exit__`** -- pooled connections had nothing to
release them, and the CLI builds one client per job.

**Re-exported from the package root**, so it is reachable as
`unstract.api_deployments.PlatformAPIClient` rather than only from the private
module.

Also corrected the `_error_text` comment from the previous commit: it described
the platform facade raising AttributeError, which is no longer a path that
exists now that both facades hand it an httpx response.

438 tests pass, up from 430. Five mutations killed: unguarded `response.json()`,
dropping the transport translation, capturing the key with the transport,
neutering `close`, and removing the re-export. The generated tree is untouched,
so `sdk-drift` is unaffected.

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

* test: cover the generated parsers the facade deliberately bypasses

Greptile, on PR #29. Two real problems, and the first is mine twice over.

**A stale docstring.** `_platform_reply` claimed that patching at
`get_httpx_client` meant "the generated parsing and model construction still
run". That was true of the first commit, which called `sync_detailed`. The
review fixes moved the facade to `_get_kwargs()` plus its own body read, and the
sentence survived the change it described -- the same failure mode as the
`openapi_schema` docstring this PR's backend counterpart had to fix.

**And the coverage the sentence was standing in for did not exist.** With the
facade reading bodies itself, nothing exercised `whoami._parse_response`,
`list_deployments._parse_response`, or any of the six new models. A regeneration
that broke them would have passed this suite.

Added, exercised directly rather than through the facade:

- every field of `WhoAmIResponse`, `PlatformKeyError` and the paginated listing,
  including the nested `APIDeploymentSummary` row;
- both new `_parse_response` functions, on a declared 200 and a declared 401;
- and the reason the facade does not use them -- a gateway's HTML 401 raises
  `ValueError` and a DRF-shaped body raises `KeyError` out of the parser. Pinning
  that keeps the facade's decision justified instead of looking arbitrary.

The tier field is a correction too: the spec declares a ChoiceField, and I had
described that as giving the client "a real enum". This generator renders it as a
`Literal` alias plus a `check_api_key_permission` validator, so the value stays a
plain string. The test now asserts what is actually emitted, and exercises the
validator in both directions.

`_deployment_page()` is shared between the facade test and the model tests: a row
that satisfied one and not the other would prove nothing about either.

441 tests pass, up from 438. Two mutations killed to confirm the new coverage is
real -- a model reading the wrong key, and a parser dropping its 401 branch. An
earlier mutation of mine (adding a default to a required `d.pop`) survived
because the test supplies the field, so it was equivalent rather than a miss.

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

* refactor: fold the platform client into a shared facade base (#30)

PR #29 gave the platform client its own copy of the transport: the pool,
the close/reopen handling and the httpx-to-requests exception translation
were duplicated from APIDeploymentsClient, and the copy had no retry policy
at all. Two copies of that code drift; only one of them was getting fixes.

Both clients now inherit `_HttpxFacade`, which owns the lazily built pooled
transport, `close`/context-manager support, the exception translation and the
retry-with-Retry-After policy. A client subclass supplies its error class and
its methods, nothing else. That drops ~184 duplicated lines and gives the
platform operations the retry behaviour the README already promised.

Three defects fall out of sharing the code:

- The transport pool is now built inside the lock. `AuthenticatedClient
  .get_httpx_client()` builds lazily and unsynchronised, so publishing the
  client before warming it let two threads build two pools.
- A close during flight no longer escapes untranslated. httpx answers a send
  on a closed client with a bare `RuntimeError`, which is not in the subtree
  `_translate_transport_errors` covers, so it reached callers catching the
  documented `requests` types. It is translated at the send.
- `list_deployments` no longer sends `workflow=None`. The generated builder
  renders that parameter with `str()` before it filters `None` out, so the
  literal string "None" went on the wire as a filter matching no workflow on
  every otherwise unfiltered call. Unset filters are omitted instead, which
  also holds if the generator special-cases another parameter later.

Exceptions get a hierarchy. `APIDeploymentsClientException` never worked --
its `__init__` nested three more `def`s that were never bound to the class,
so `message` was dropped and `Exception.__init__` was never called, leaving
`str(e)` empty and the documented `error_message()` non-existent. It is now
an alias of a new `UnstractError` base, with `APIDeploymentError` and
`PlatformClientError` beneath it. Catching the old name still catches both
clients, including anything added later.

Also here:

- The generated models are re-exported, so callers who want typing can
  `WhoAmIResponse.from_dict(...)` instead of us hand-writing a mirror of the
  spec that regeneration would not update. Facade methods keep returning
  `dict[str, Any]`.
- The platform client gets its own logger. Both clients shared the module
  logger, so levelling one re-levelled the other, switching a live sibling's
  debug output -- which includes response bodies -- on or off as a side effect.
- A 2xx body that is unreadable, or JSON that is not an object, is now an
  error naming what arrived rather than an `AttributeError` downstream. The
  ERROR log for it is bounded to the same excerpt the exception carries.
- An `org_id` that is empty or blank is refused before the request, and a path
  on `base_url` is warned about rather than silently discarded by `urljoin`.
- `.claude/skills/spec-upgrade/SKILL.md` step 5 gains the recipe for adding an
  operation: which class it belongs to, the method shape, and why it builds
  from `_get_kwargs` rather than `sync_detailed`.

No runtime breaking change. The one visible shift is `type(e).__name__`, which
becomes `APIDeploymentError` where it was `APIDeploymentsClientException`;
`except APIDeploymentsClientException` is unaffected.


Claude-Session: https://claude.ai/code/session_01CnhmFYFRBrK376tZyDtwUM

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com>
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