Skip to content

fix(http): never replay a credential-minting call on an ambiguous failure (#599) - #601

Draft
padak wants to merge 1 commit into
mainfrom
claude/issue-599-non-idempotent-retry
Draft

fix(http): never replay a credential-minting call on an ambiguous failure (#599)#601
padak wants to merge 1 commit into
mainfrom
claude/issue-599-non-idempotent-retry

Conversation

@padak

@padak padak commented Aug 18, 2026

Copy link
Copy Markdown
Member

What

Two CLI-side hardening asks from #599, whose reporter correctly concluded the underlying 500 on europe-west3.gcp is upstream and not a kbagent bug.

1. Non-idempotent calls are no longer replayed on an ambiguous failure.

POST /v2/storage/tokens mints a credential, but the shared retry loop treated it like any other request and replayed it up to 3 times on 500/502/503/504 — and on read timeouts. If the server ever mints the token and then fails to answer, the replay leaves a second live credential behind that the caller never sees a value for, and therefore can never revoke.

BaseHttpClient._do_request now takes idempotent (default True, so every existing call site is byte-identical). Four call sites pass False:

  • create_scoped_token and create_short_lived_token (POST /v2/storage/tokens)
  • create_git_credential (data-app managed repo — mints a one-time http_token secret)

The distinction is not "is it a POST" but "could the request have reached the handler":

Failure Non-idempotent Why
429 still retried rejected by the rate limiter before the handler runs
connect error / connect timeout still retried connection never established
5xx not retried server may have completed the write and failed to answer
read/write timeout not retried request may have been delivered and applied

So the change costs no resilience anywhere replay is provably safe.

token refresh is deliberately left retryable: a replayed rotation overwrites the value the lost response carried rather than leaving a second credential behind, so the caller still ends up holding the only token that authenticates. Documented in the method docstring so it does not read as an oversight.

2. A 5xx now says it is upstream, and carries the support id.

API error 500 ... Application error. gave the caller no next step — which is exactly why the reporter spent an investigation across two projects and two tokens before concluding it was not theirs to fix. Server errors now append the status-page/support hint, plus (exceptionId: ...) whenever the response body carries one (the first thing Keboola support asks for; previously parsed and discarded).

4xx messages are untouched — a rejected request is the caller's to fix and must not blame upstream. A non-idempotent 5xx additionally reports retryable: false in the --json error envelope, so an automated caller does not replay a mint the server may already have honoured. Nothing in this repo branches on retryable; it is advice carried to the caller.

Not in this PR

  • The upstream 500 itself — a Keboola Connection backend issue, as the reporter says.
  • kbagent token list, asked for in a follow-up comment on the same issue. Separate scope (new command + all doc surfaces), tracked separately.

How it was tested

make check green: lint, format, typecheck, skill, version, command-sync, changelog, error-codes, sentinel-guards, file-size, 5758 tests passed.

New tests:

  • tests/test_http_base.py::TestNonIdempotentRetryPolicy — 7 cases pinning each row of the table above, plus a case proving the default (idempotent) path still makes its 3 attempts.
  • tests/test_http_base.py::TestUpstreamErrorGuidance — hint on 5xx, exceptionId surfaced when present, no hint on 4xx.
  • tests/test_client_device_enrollment.py::TestCreateScopedTokenIsNotReplayed — the reported scenario at the client layer: a persistent 500 on create_scoped_token costs exactly one attempt.

No live-API testing: reproducing this needs the upstream outage.

Note for the maintainer

The changelog entry sits under 0.84.2, the current top key, since main is exactly v0.84.2 and there is no unreleased key. Renumber at release time. No command was added, renamed or removed, so no command doc surface needed syncing; docs/sdk.md gained one line because create_scoped_token is public SDK API and its retry behaviour changed.

Fixes #599

…lure (#599)

POST /v2/storage/tokens is a create, not a read, yet the shared retry loop
replayed it up to 3 times on 500/502/503/504 and on read timeouts. A server
that mints the token and then fails to answer hands back a second live
credential the caller never sees a value for -- and therefore can never
revoke.

_do_request now takes `idempotent` (default True, so every existing call site
is byte-identical); the two token-create paths and the data-app managed-repo
git-credentials-create pass False. Failures that provably never reached the
handler stay retryable even then -- 429 (rejected by the rate limiter before
the handler runs), connect error, connect timeout -- so no resilience is lost
where replay is safe. token refresh is deliberately left retryable: a replayed
rotation overwrites the value the lost response carried instead of leaving a
second credential behind.

Second half of the issue: a 5xx now names itself as upstream and carries the
support id. `API error 500 ... Application error.` gave the caller no next
step, so a persistent upstream outage read like a broken request. Server
errors now append the status-page/support hint plus `(exceptionId: ...)`
when the body carries one; 4xx messages are untouched. A non-idempotent 5xx
also reports retryable: false in the --json envelope.
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.

token create: persistent upstream 500 on POST /v2/storage/tokens surfaces as a blind retry + generic error, no actionable guidance

1 participant