Skip to content

fix(sdk): surface API error bodies on 4xx/5xx and stop dropping header params - #41

Merged
Zernio-Elean merged 2 commits into
developfrom
fix/sdk-error-details-and-header-params
Sep 4, 2026
Merged

fix(sdk): surface API error bodies on 4xx/5xx and stop dropping header params#41
Zernio-Elean merged 2 commits into
developfrom
fix/sdk-error-details-and-header-params

Conversation

@Zernio-Elean

@Zernio-Elean Zernio-Elean commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Two source-confirmed client-side bugs reported by an integrator building publishing on the Python SDK. Both are purely SDK defects: the server behaves correctly in each case.

  • 401/403/404 discarded the response body, so callers could never branch on the 403 code discriminator that separates a dead token (ACCOUNT_DISCONNECTED, needs user re-auth) from an unknown/misconfigured accountId (a config fix on their side). Those are two different remediations and the SDK made them indistinguishable.
  • Every in: header parameter was silently dropped by the resource generator. posts.create_post(x_request_id=...) was accepted and thrown away, making the server's ~5 minute idempotency window unreachable from Python. This turned out to affect 12 params across 5 resources, not just the one reported.

Changes

fix(client)LateAuthenticationError / LateForbiddenError / LateNotFoundError now accept details, and the 401/403/404 branches of _handle_response forward the parsed body. LateAPIError.__str__ already renders code:, so str(exc) surfaces the discriminator for free, including through the MCP wrapper.

Parsing goes through a guarded _parse_error_body instead of a bare response.json(). Adding an unguarded parse to the 401 branch would have been a regression: an HTML 401 from a proxy raises JSONDecodeError, which _request_with_retry does not catch, so it would escape instead of surfacing as LateAuthenticationError. The same guard is applied to the generic >= 400 branch, where an HTML 502 is the likeliest trigger of all, and whose isinstance(error_data, dict) guard was dead code because .get() crashed one line earlier.

fix(generator)generate_method_body gains a header_params bucket. Header dicts are emitted inline keyed on the verbatim wire name, so x-request-id stays lowercase where _build_params would have camelCased it. headers is threaded through _get/_aget/_post/_apost only, including the separate httpx client built for multipart uploads; no PUT/PATCH/DELETE operation declares a header param today.

Regenerating surfaced 12 dropped params: x-request-id (posts), X-Connect-Token (5 connect flows, two of them required positionals the caller was forced to pass), Idempotency-Key (4 ad campaign ops) and X-Filename (2 KYC uploads).

Testing

The regression tests are deliberately not part of this diff. They were written and run against this branch in a local worktree; the output below is that run. Treat this section as the evidence, since the diff itself carries none.

Full suite, lint and types on this branch:

$ uv run pytest --no-cov -q
237 passed, 14 skipped in 1.74s

$ uv run ruff check src tests
All checks passed!

$ uv run mypy src --ignore-missing-imports
Success: no issues found in 117 source files

The five tests covering this change, on this branch:

$ uv run pytest --no-cov -v tests/test_error_details.py tests/test_header_params.py
test_403_with_code_exposes_it_and_renders_in_str                    PASSED
test_403_without_code_raises_cleanly                                PASSED
test_401_with_non_json_html_body_still_raises_authentication_error  PASSED
test_500_with_non_json_html_body_still_raises_api_error             PASSED
test_create_post_sends_x_request_id_header_and_keeps_authorization  PASSED
5 passed in 0.28s

The same five against develop, to show they fail for the real defect and not by construction:

$ git checkout origin/develop -- src/ scripts/
$ uv run pytest --no-cov -q tests/test_error_details.py tests/test_header_params.py

test_403_with_code_exposes_it_and_renders_in_str
  KeyError: 'code'

test_403_without_code_raises_cleanly
  AssertionError: assert {} == {'error': 'Unknown accountId'}

test_500_with_non_json_html_body_still_raises_api_error
  json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

test_create_post_sends_x_request_id_header_and_keeps_authorization
  AssertionError: assert 'x-request-id' in ['Host', 'Accept-Encoding',
  'Connection', 'Authorization', 'Content-Type', 'Accept', ...]

Four of the five fail against develop. The fifth, the HTML 401, passes there because develop's 401 branch never called .json() at all; it guards against the naive intermediate fix instead. Verified separately by patching only that one line to an unguarded response.json(), which makes it fail with json.decoder.JSONDecodeError: Expecting value: line 1 column 1.

Other checks:

  • Transport-level fakes (respx), no internal mocking.
  • Commit 1 passes standalone (236 passed / 14 skipped), so the split stays bisectable.
  • Regeneration reproduced byte-identically in a scratch tree; the other 53 generated files are unchanged.
  • Multipart uploads captured on the wire: X-Filename delivered, Authorization preserved, Content-Type not duplicated.

Notes for the reviewer

Targets develop, not main, deliberately. The auto-regen workflow publishes the wheel from develop, and CHANGELOG.md [1.4.49] documents what happens otherwise: the _patch fix landed on main only and develop's next regen republished the wheel without it.

After regenerating, ruff check --fix + ruff format must be run on src/late/resources/_generated/ per .github/workflows/generate.yml. Without it the diff churns all 58 generated files instead of the 5 that actually changed.

Known gaps, deliberately out of scope

  • Header params supplied via a component $ref are still discarded entirely by the $ref branch of extract_parameters, which handles only PageParam and LimitParam. That leaves Idempotency-Key unreachable on 9 further operations: createProfile, sendInboxMessage, replyToInboxPost, replyToInboxReview, initiateWhatsAppCall, createVoiceCall, sendSms, boostPost, createStandaloneAd.
  • The hand-written posts.create() still has no x_request_id; only the generated create_post() does, and it returns a raw dict rather than a typed PostCreateResponse. Callers currently trade type safety for idempotency.
  • The SDK still does not auto-generate an x-request-id per call, which openapi.yaml claims the official SDKs do and which the server assumes.
  • No regression test ships with this PR, so nothing prevents a future codegen change from reintroducing either defect.

Crisp

https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_0aff04b5-6834-480a-ba49-f525956329db/

🤖 Generated with Claude Code

https://claude.ai/code/session_01QyoePpXUo8PHpGUr9JTu23

_handle_response built the 401/403/404 exceptions from error_data["error"]
alone and dropped the rest of the body, and the three subclasses did not
accept a details kwarg at all. Callers could never branch on the 403 `code`
discriminator that distinguishes a dead token (ACCOUNT_DISCONNECTED, needs
re-auth) from an unknown accountId (a config fix).

Body parsing goes through a guarded _parse_error_body rather than a bare
response.json(): an HTML error page from a proxy in front of the API would
otherwise escape as JSONDecodeError instead of a Late* exception, since
_request_with_retry does not catch it. Applied to the generic >= 400 branch
too, where an HTML 502 is the likeliest trigger of all, and whose
isinstance(error_data, dict) guard was dead code because .get() crashed first.

Reported by ForgeWorks on zernio-sdk 1.4.624:
https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_0aff04b5-6834-480a-ba49-f525956329db/
@Zernio-Elean
Zernio-Elean force-pushed the fix/sdk-error-details-and-header-params branch from 19a5455 to a4a822e Compare September 4, 2026 08:00
extract_parameters captured "in": "header" params but generate_method_body
bucketed only query/body/raw_body/path, so every header param was dropped at
emission: posts.create_post(x_request_id=...) was accepted and thrown away,
making the server's idempotency window unreachable from Python. BaseClient
also had no headers kwarg on _post.

Header dicts are emitted inline keyed on the verbatim wire name, so
x-request-id stays lowercase where _build_params would have camelCased it.
headers is threaded through _get/_aget/_post/_apost only, including the
separate httpx client built for multipart uploads; no PUT/PATCH/DELETE
operation declares a header param today.

Regenerates connect, ad_campaigns, phone_numbers, posts and
whatsapp_phone_numbers: 12 header params across those five were being
dropped, not just x-request-id.

Header params supplied via a component $ref (Idempotency-Key on 9 further
operations) are still discarded by the $ref branch of extract_parameters and
are left for a follow-up.

https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_0aff04b5-6834-480a-ba49-f525956329db/
@Zernio-Elean
Zernio-Elean force-pushed the fix/sdk-error-details-and-header-params branch from a4a822e to 55cdcc9 Compare September 4, 2026 08:30
@Zernio-Elean
Zernio-Elean merged commit 7c0a163 into develop Sep 4, 2026
4 checks passed
Zernio-Elean added a commit that referenced this pull request Sep 4, 2026
…every request (#42)

* fix(client): send an x-request-id on every request, reused across retries

The SDK never emitted x-request-id, so the server could not match a
replayed request to the original and its idempotency window was
unreachable from Python. openapi.yaml already claims the official SDKs
send one. Minted once before the retry loop so every attempt of the same
logical call carries the same id; a caller-supplied id always wins.

Closes the first item in PR #41's "Known gaps".

* fix(client): stop replaying timed-out POSTs and give publishNow 300s

A publishNow create publishes synchronously and can run for minutes; one
measured Threads publish took 222s against a 30s DEFAULT_TIMEOUT. httpx
aborted mid-publish and _request_with_retry replayed the POST, so the
customer got a 409 for a post that had gone live plus a duplicate live
post. POST is non-idempotent by contract, so a client-side timeout now
raises instead of replaying; ConnectError still retries, since nothing
reached the server. publishNow creates get a 300s timeout, overridable
via Zernio(publish_timeout=...).

Crisp: https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_8e5d3e6e-1e10-4a33-95f1-0b1e33d119da/
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