fix(client): treat HTTP 422 as a fatal transport failure - #117
Merged
Merged
Conversation
Implements ai-sdks-monorepo #29 (§3.25, A.12), which specifies the opposite of #23 and is expected to supersede it. 422 was classified as a third thing -- neither broken nor terminal -- and that shape is a retry loop with no bound and no budget: it never moved `_failures`, so it retried at `max_backoff` for the life of the process, invisible to both `failed` and `connection_failures`. The platform chose the status to be terminal rather than us inferring it. The streamer says so at both sites that produce it -- the narrow-assignment check and the status constant -- each noting the code exists so a misconfigured SDK stops instead of hammering the fleet. Retrying forever is the precise behavior it was picked to prevent. The stated cause was also wrong, which is what made the old handling look reasonable. The gate is `payloadvers.SkillDeliveryAllowed`: delivery enabled for the account, and a credential that is not view-scoped. Every way of failing it is permanent. Skill existence is not among the causes -- with the gate open and a non-view-scoped key, the assignment path creates the agent-skill payload row lazily, so an environment holding zero skills is assigned an empty payload that commits normally through `payload-transferred`. So the message is rewritten as well as the classification. It names the two causes a reader can act on, and drops the two false claims the old text made: that the condition is about whether any skill exists, and that a skill created later arrives without a restart. It is what a customer pastes into a support ticket, so it is asserted on substance. Routing 422 through `_give_up` needed no new code and buys the right accounting: `failed` and `last_error` are set and `connection_failures` is untouched, because that counter measures consecutive *recoverable* failures against the retry bound and a fatal never retries. It also makes `wait_for_skills` return `False` immediately, through the existing `_end_delivery` release -- the observable half of the classification, so a boot gated on skills stops paying its whole timeout. Both are asserted rather than implemented. `_NoSkillPayloadError` and `diagnostics.payload_unavailable` are deleted. Both absences are asserted by name, the way §3.22 asserts its unexported constants, since a reintroduction is otherwise visible only in a log line no test reads; the diagnostics field set is pinned whole alongside. Classification is asserted to answer every status in 300..599 with exactly one of the two classes, so the forbidden shape cannot return under a different name. The `?kinds=agent-skill` declaration and the kind constants are unchanged; they already conform. README and agents.md described the deleted field and the retry behaviour, so both are rewritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit a359b3b. Configure here.
| return _FatalTransportError( | ||
| "LaunchDarkly will not deliver Agent Skills on this connection " | ||
| "(HTTP 422). The usual cause is a view-scoped SDK key. Check your " | ||
| "SDK key or contact LaunchDarkly support. |
There was a problem hiding this comment.
422 error message is truncated
High Severity
The HTTP 422 _FatalTransportError string is cut off mid-sentence, so the module does not parse and failed never names account-level enablement or that a restart is required. test_the_422_message_names_both_of_its_real_causes asserts those tokens on the customer-facing reason.
Reviewed by Cursor Bugbot for commit a359b3b. Configure here.
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.


Stacked on #109 (
xie/python-skills-kinds-param) — review that first, or read this diff alone, which touches only the 422 classification and the diagnostics field it fed.Implements ai-sdks-monorepo #29 (§3.25, A.12). That spec deliberately leaves this SDK non-conforming until this change lands.
Important
ai-sdks-monorepo #23 specifies the opposite — 422 as a third class, counted and retried at the backoff cap — and is expected to be closed as superseded. This PR implements #29, not #23.
Why 422 is fatal
The platform chose the status to be terminal; this SDK is not inferring it. The
streamerrepo says so in both places that produce it —internal/fdcore/narrowassign/narrowassign.go:24andinternal/fdcore/adapters/httpsrv/status/status.go— each noting that SDKs treat a non-400 4xx as terminal, so a misconfigured SDK stops instead of hammering the fleet. Retrying forever is the precise behaviour the status was picked to prevent.The previous handling was a third class: neither broken nor terminal. It never moved
_failures, so it retried atmax_backofffor the life of the process, invisible to bothfailedandconnection_failures— a loop with no bound and no budget.The stated cause was also wrong
That is what made the old handling look reasonable. The gate is
payloadvers.SkillDeliveryAllowedin gonfalon: delivery enabled for the account and a credential that is not view-scoped. Every way of failing it is permanent.Skill existence is not among the causes. With the gate open and a non-view-scoped key, the assignment path creates the agent-skill payload row lazily, so an environment holding zero skills is assigned an empty payload that commits normally through
payload-transferred. Nothing here assumes gonfalon #73018 (eager row creation) lands — it is closed.So the message is rewritten alongside the classification. It names the two causes a reader can act on, and drops the two false claims the old text made: that the condition is about whether any skill exists, and that a skill created later arrives without a restart. It is what a customer pastes into a support ticket, so it is asserted on substance rather than prose.
The third cause the spec lists — a typo'd
kindsvalue — is deliberately not in the message: this SDK sends a module constant, so it is not reachable by a customer.What changed
skills_fdv2.py_classify_statusreturns_FatalTransportErrorfor 422, in its own branch;_NoSkillPayloadError, theexceptblock in_run, the_warned_no_skill_payloadflag, andStoreDiagnostics.payload_unavailableare deletedtest_skills_fdv2.pyREADME.md,agents.mdA dedicated branch rather than folding 422 into the existing
(405, 406, 414, 501)list: that list's_REQUEST_ADVICEpoints the reader at the base URI, which is not where the problem is.Routing 422 through
_give_upneeded no new code, and buys the right accounting for free —failedandlast_errorare set andconnection_failuresis untouched, because that counter measures consecutive recoverable failures against the retry bound and a fatal never retries. It also makeswait_for_skillsreturnFalseimmediately, through the existing_end_deliveryrelease. Both are asserted rather than implemented.Tests
1132 pass; lint, format, and
mypy --strictclean. Each new assertion was verified to bite, by mutating the source three ways:Exception(a genuine third class)HTTP 422 classified as Exception, which is neither exactly recoverable nor exactly fatal_NoSkillPayloadErrorandpayload_unavailableThe shape test sweeps
range(300, 600)and asserts exactly one class per status via XOR, so the forbidden shape cannot return under a different name. The two absences are asserted by name — the way §3.22 asserts its unexported constants — since a reintroduction is otherwise visible only in a log line no test reads; the diagnostics field set is pinned whole alongside. Thewait_for_skillstest asserts value and elapsed time against a 30s timeout.The one cost, from the spec
When an account's gate opens, a process whose store already gave up needs a restart — nothing reopens delivery short of constructing a new store, since
closeis final and a laterstartraises. Acceptable for a beta enablement step, and deliberately preferred to retrying a permanent rejection for the life of the process. The README andagents.mdboth say so, so it is not rediscovered as a bug.Scope
The
?kinds=agent-skilldeclaration and the payload/object kind constants are unchanged — they already conform. Nothing else in the transport is touched.🤖 Generated with Claude Code
Note
Overview
Agent Skills FDv2 transport now treats HTTP 422 as terminal, aligning with platform intent that non-400 4xx responses stop SDKs instead of retrying forever.
_classify_statusmaps 422 to_FatalTransportErrorwith updated guidance (view-scoped SDK key, account-level Agent Skills enablement, restart to recover). The prior third path is removed:_NoSkillPayloadError, infinite retry atmax_backoff,_warned_no_skill_payload, andStoreDiagnostics.payload_unavailable. On 422 the store uses the existing fatal path—setsfailed/last_error, leavesconnection_failuresat 0, stops polling/streaming, andwait_for_skillsreturnsfalseimmediately.Docs in README and agents.md no longer describe 422 as “no skills yet”; they state that a valid key with delivery enabled gets an empty payload for zero skills, not 422. Tests are rewritten to pin fatal classification, diagnostic shape, messaging, and prune/
write_skillsbehavior when delivery has given up.Reviewed by Cursor Bugbot for commit a359b3b. Bugbot is set up for automated code reviews on this repo. Configure here.