Skip to content

fix(client): treat HTTP 422 as a fatal transport failure - #117

Merged
XieX merged 4 commits into
xie/python-skills-kinds-paramfrom
xie/fatal-422
Sep 29, 2026
Merged

XieX merged 4 commits into
xie/python-skills-kinds-paramfrom
xie/fatal-422

Conversation

@XieX

@XieX XieX commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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 streamer repo says so in both places that produce it — internal/fdcore/narrowassign/narrowassign.go:24 and internal/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 at max_backoff for the life of the process, invisible to both failed and connection_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.SkillDeliveryAllowed in 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 kinds value — is deliberately not in the message: this SDK sends a module constant, so it is not reachable by a customer.

What changed

File Change
skills_fdv2.py _classify_status returns _FatalTransportError for 422, in its own branch; _NoSkillPayloadError, the except block in _run, the _warned_no_skill_payload flag, and StoreDiagnostics.payload_unavailable are deleted
test_skills_fdv2.py Nine tests covering the new behaviour; the three that asserted the old behaviour are inverted or deleted
README.md, agents.md Both described the deleted field and the retry behaviour

A dedicated branch rather than folding 422 into the existing (405, 406, 414, 501) list: that list's _REQUEST_ADVICE points the reader at the base URI, which is not where the problem is.

Routing 422 through _give_up needed no new code, and buys the right accounting for free — 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. Both are asserted rather than implemented.

Tests

1132 pass; lint, format, and mypy --strict clean. Each new assertion was verified to bite, by mutating the source three ways:

Mutation Result
422 → recoverable all five end-to-end 422 tests fail, plus the classification test
422 → bare Exception (a genuine third class) shape test fails: HTTP 422 classified as Exception, which is neither exactly recoverable nor exactly fatal
Reintroduce _NoSkillPayloadError and payload_unavailable both absence tests fail

The 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. The wait_for_skills test 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 close is final and a later start raises. Acceptable for a beta enablement step, and deliberately preferred to retrying a permanent rejection for the life of the process. The README and agents.md both say so, so it is not rediscovered as a bug.

Scope

The ?kinds=agent-skill declaration 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_status maps 422 to _FatalTransportError with updated guidance (view-scoped SDK key, account-level Agent Skills enablement, restart to recover). The prior third path is removed: _NoSkillPayloadError, infinite retry at max_backoff, _warned_no_skill_payload, and StoreDiagnostics.payload_unavailable. On 422 the store uses the existing fatal path—sets failed / last_error, leaves connection_failures at 0, stops polling/streaming, and wait_for_skills returns false immediately.

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_skills behavior 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.

XieX and others added 4 commits September 29, 2026 12:22
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>
@XieX
XieX marked this pull request as ready for review September 29, 2026 17:12
@XieX
XieX merged commit a6ca2cc into xie/python-skills-kinds-param Sep 29, 2026
2 of 5 checks passed
@XieX
XieX deleted the xie/fatal-422 branch September 29, 2026 17:12

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a359b3b. Configure here.

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