fix(sdk): surface API error bodies on 4xx/5xx and stop dropping header params - #41
Merged
Merged
Conversation
_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
force-pushed
the
fix/sdk-error-details-and-header-params
branch
from
September 4, 2026 08:00
19a5455 to
a4a822e
Compare
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
force-pushed
the
fix/sdk-error-details-and-header-params
branch
from
September 4, 2026 08:30
a4a822e to
55cdcc9
Compare
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/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
codediscriminator that separates a dead token (ACCOUNT_DISCONNECTED, needs user re-auth) from an unknown/misconfiguredaccountId(a config fix on their side). Those are two different remediations and the SDK made them indistinguishable.in: headerparameter 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/LateNotFoundErrornow acceptdetails, and the 401/403/404 branches of_handle_responseforward the parsed body.LateAPIError.__str__already renderscode:, sostr(exc)surfaces the discriminator for free, including through the MCP wrapper.Parsing goes through a guarded
_parse_error_bodyinstead of a bareresponse.json(). Adding an unguarded parse to the 401 branch would have been a regression: an HTML 401 from a proxy raisesJSONDecodeError, which_request_with_retrydoes not catch, so it would escape instead of surfacing asLateAuthenticationError. The same guard is applied to the generic>= 400branch, where an HTML 502 is the likeliest trigger of all, and whoseisinstance(error_data, dict)guard was dead code because.get()crashed one line earlier.fix(generator)—generate_method_bodygains aheader_paramsbucket. Header dicts are emitted inline keyed on the verbatim wire name, sox-request-idstays lowercase where_build_paramswould have camelCased it.headersis threaded through_get/_aget/_post/_apostonly, 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) andX-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:
The five tests covering this change, on this branch:
The same five against
develop, to show they fail for the real defect and not by construction:Four of the five fail against
develop. The fifth, the HTML 401, passes there becausedevelop'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 unguardedresponse.json(), which makes it fail withjson.decoder.JSONDecodeError: Expecting value: line 1 column 1.Other checks:
respx), no internal mocking.X-Filenamedelivered,Authorizationpreserved,Content-Typenot duplicated.Notes for the reviewer
Targets
develop, notmain, deliberately. The auto-regen workflow publishes the wheel fromdevelop, andCHANGELOG.md [1.4.49]documents what happens otherwise: the_patchfix landed onmainonly and develop's next regen republished the wheel without it.After regenerating,
ruff check --fix+ruff formatmust be run onsrc/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
$refare still discarded entirely by the$refbranch ofextract_parameters, which handles onlyPageParamandLimitParam. That leavesIdempotency-Keyunreachable on 9 further operations:createProfile,sendInboxMessage,replyToInboxPost,replyToInboxReview,initiateWhatsAppCall,createVoiceCall,sendSms,boostPost,createStandaloneAd.posts.create()still has nox_request_id; only the generatedcreate_post()does, and it returns a rawdictrather than a typedPostCreateResponse. Callers currently trade type safety for idempotency.x-request-idper call, whichopenapi.yamlclaims the official SDKs do and which the server assumes.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