feat(token): add token list, stop retrying non-idempotent writes (#599) - #616
Conversation
) Issue #599 reported a persistent upstream 500 from `POST /v2/storage/tokens` on the europe-west3.gcp stack and flagged two client-side gaps it exposed. Both are real, and the first is worse than reported. Retry policy (http_base.py) kbagent retried every request on 429/500/502/503/504 regardless of method. Reading connection's `Storage_Service_Tokens::createToken` shows why that is unsafe for a write: the token row is saved, its secret generated, and only then are bucket permissions resolved -- outside any transaction (unlike `updateToken` right below it, which wraps the same work in one). A 500 raised in that block leaves a live token behind that the caller never sees, so three attempts could leave three of them. The reporter framed the duplicate-mint risk as hypothetical; that branch is reachable. - 5xx is retried only on GET/HEAD/OPTIONS/PUT/DELETE (RETRY_SAFE_METHODS). - 429 is still retried on every method -- the server states it did not process the request. - Transport failures split by what they prove: a refused connection or a connect/pool timeout never delivered the request and is still retried on any method; a read/write timeout means the request WAS sent, so it is not retried on POST/PATCH. That path is the same hazard as the 500 one, just quieter, and fixing only the status-code branch would have left it open. - A 500 on an unretried write reports retryable=false, so callers do not treat it as a transient blip. This covers all 29 POST call sites, not just the token mint -- `config oauth-url` mints a token through the very same endpoint. Error guidance (_raise_api_error) The parser picked up Keboola's generic `error: "Application error."` and dropped the `exceptionId` beside it -- the only handle Keboola support can trace an incident by. It is now surfaced, plus one of two hints: the request was not retried because the method is not idempotent (verify before repeating), or the same 5xx survived all attempts and is an upstream incident (escalate with the id). 4xx is untouched -- the incident hint would mislead. `kbagent token list` (issue #599 comment) The group could mint, revoke and rotate but not enumerate, so there was no way to get the `--token-id` that delete/refresh need without the web UI. It is also the check the new retry behaviour tells you to run after an unretried mint failure, which is why it ships here rather than separately. Secrets are stripped from every row, `--json` included: on a project carrying `force-decrypted-token` the Storage API embeds live values in the listing, and reproducing them would break the group's "revealed once, at mint" contract for every token at once. Stripped at the service layer and again in the SDK facade. Full surface: client `list_tokens()`, `TokenService.list_tokens()`, the CLI command, `GET /token/{project}/list` on serve, `token.list: read` in the permission registry, and `Client.list_tokens() -> list[TokenListEntryResult]` on the SDK. Version is deliberately NOT bumped -- this lands in a stack of PRs released as one version. The `(since v0.86.0)` tags in gotchas.md / commands-reference / sdk.md assume 0.86.0; the bump PR must confirm that.
padak
left a comment
There was a problem hiding this comment.
Review of #616 — feat(token): add token list, stop retrying non-idempotent writes (#599)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
This PR does three related things for issue #599: (1) stops retrying POST/PATCH on 5xx responses (only GET/HEAD/OPTIONS/PUT/DELETE + 429 stay retryable, per RFC 9110 idempotent-method semantics), splitting timeout retries the same way; (2) surfaces the upstream exceptionId plus an actionable hint on 5xx errors; (3) adds kbagent token list (client/service/CLI/server-router/SDK/permissions), with secrets stripped at every layer. Documentation and silent-drift surfaces (context.py, CLAUDE.md, keboola-expert.md, gotchas.md, commands-reference.md, SKILL.md, docs/sdk.md, permissions.py) are all updated and version-tagged (since v0.86.0). make check passes clean (5597 passed, 0 failed) and scripts/check_command_sync.py reports all 259 commands registered/documented. Verdict: APPROVE — no blocking issues found; a couple of small non-blocking notes below.
Verdict
- Verdict: APPROVE
- Blocking findings: 0
- Non-blocking findings: 2
- Nits: 1
Blocking findings
(none)
Non-blocking findings
[NB-1] CLAUDE.md:590 (release-time risk) — (since v0.86.0) tags depend on an unconfirmed future version number
pyproject.toml is still 0.85.0 on this branch, but every new gotcha/reference tag in this PR (gotchas.md:3713, gotchas.md:3754, commands-reference.md:203, keboola-expert.md:173,179) says since v0.86.0. The PR description explicitly flags this ("the bump PR must confirm that number and adjust them if it differs") — so this is not an oversight, but it is a concrete, trackable risk that the version-bump PR must not skip: if the eventual bump lands as e.g. 0.86.1 or a different stacked PR grabs 0.86.0 first, all five tags need a follow-up edit or they mislead the AI agent about the version floor. Recommend the release PR checklist explicitly greps for v0.86.0 in plugins/ before tagging.
[NB-2] src/keboola_agent_cli/client/merge_requests.py:318 (out of scope for this PR, worth a follow-up issue) — RETRY_SAFE_METHODS assumes every PUT in the codebase is a resource replacement, but merge_requests.py uses PUT .../merge, PUT .../request-review, and PUT .../approve as action-transition endpoints, not idempotent resource PUTs
This file is untouched by the current diff, so the behavior here is unchanged (it was already unconditionally retried on 5xx before this PR, and stays so now since PUT is in RETRY_SAFE_METHODS). Flagging only because this PR's new abstraction (RETRY_SAFE_METHODS = "RFC 9110 idempotent set") now formally documents PUT as safe-to-repeat everywhere, and merge_requests.py's action-style PUTs are the one place in the client layer where that classification is closer to POST-shaped ("merge this MR") than to "replace this resource". Worth a follow-up look (not this PR) at whether those three calls should be excluded from the retry-safe set the same way POST/PATCH now are.
Nits
[NIT-1]PR title/commit — the single commit bundles afeat(newtoken listcommand) with what is really afix(non-idempotent-write retry regression, arguably the more significant change of the two). The PR description justifies bundling them ("it is also the check the new retry behaviour tells you to run"), which is a reasonable call, but afix(http):prefix would have been a more accurate headline for the retry-safety change if these ever need to be cherry-picked independently.
Verification log
gh pr view 616 --json title,body,files,additions,deletions,...→ 27 files, +886/-53, titlefeat(token): add \token list`, stop retrying non-idempotent writes (#599)` ✓git rev-parse --abbrev-ref HEAD→claude/issue-599-855b69, matches<branch>input ✓ (working tree already on the PR's branch, no checkout needed)- Read
CONTRIBUTING.md(Checklist: Adding a New CLI Command, Plugin synchronization map, Releasing a new version) ✓ - Read
CLAUDE.mdconvention #17 +## All CLI Commands(token listpresent at the documented line) ✓ - Read
plugins/kbagent/agents/keboola-expert.md§1, §2 (existingtokenmatrix row coverslist— no new row needed since it's an addition to an existing group, not a new group), §3 (two new gotcha triggers added) ✓ gh auth status→ authenticated aspadak, scopes includerepo✓- 3-layer grep (typer/formatter in services, httpx in commands, formatter/typer in clients) → all empty, no violations ✓
- Silent-drift grep of
git diff main...HEAD -- cli.py commands/**/*.pyfor new@*_app.command→ onlytoken list; cross-checked againstcontext.pyAGENT_CONTEXT (present),CLAUDE.md(present),commands-reference.md(present),gotchas.md(present, version-tagged),permissions.pyOPERATION_REGISTRY("token.list": "read"present),server/routers/token.py(newGET /{project}/listroute present, 1:1 with CLI) ✓ uv run python scripts/check_command_sync.py→OK: all 259 CLI commands are registered (OPERATION_REGISTRY) and documented (CLAUDE.md, context.py, commands-reference.md).✓wc -c plugins/kbagent/agents/keboola-expert.md→ 61257 bytes, matches the PR description's claimed byte count exactly (62000 cap, ~740 bytes headroom) ✓- Convention greps (magic numbers, raw error-code literals in
src/, bareexcept:,print()in production code, token leakage in logs, new baretuple[...]returns) → all clean; the one rawerror_code="ACCESS_DENIED"hit is intests/test_token_cli.py, whichmake check-error-codesdeliberately excludes (test assertions, not production code) ✓ make check(lint + format + typecheck + skill freshness + version sync + command-sync + changelog-check + error-codes + sentinel-guards + full test suite) →5597 passed, 12 skipped, 152 deselected, 2 warnings, exit 0✓ (ran in background, ~2m02s; output confirms no lint/typecheck/format failures preceded the test run sincemake checkis sequential-and-exits-on-first-failure)- Reviewed
http_base.pyretry-gate logic line by line:RETRY_SAFE_METHODSgate on 5xx,429exempted,ConnectTimeout/PoolTimeouttreated as "never delivered" (still retried on any method) vs. read/writeTimeoutExceptiontreated as "outcome unknown" (gated),exceptionIdextraction + truncation-ordering (hint appended after body truncation, so it always survives) — all internally consistent with the stated design in the PR description and docstrings ✓ - Spot-checked existing
PUTcall sites acrossclient/(storage_tables.py,configs.py,merge_requests.py) for whether "PUT stays retry-safe" holds semantically — mostly true (state replacement, sharing toggle); one soft exception noted as NB-2, out of this PR's diff ✓ - Could not run
make test-e2e(noE2E_API_TOKEN/E2E_URLin this environment, and per project convention AI agents never handle live API tokens) — relied on the newtest_scoped_token_mint_rotate_revokeE2E test's structure (reviewed, not executed) plus the 15 newtest_http_base.pyunit tests and the service/CLI/lib mock-based tests, all of which did execute and pass undermake check
Open questions for the author
(none)
…tionId Two findings from Devin Review on #616. 1. A write that was first rate-limited got the wrong recovery advice. A POST can legitimately reach a second attempt via a 429 (which stays retryable on every method). If that attempt answered 500, the hint was picked by attempt count, so the operator was told "the same 5xx came back on all 2 attempts, likely an upstream incident, escalate" -- factually wrong (one 5xx was seen) and, worse, it replaced the "verify what already landed" warning on exactly the request that most needed it. Telling someone to escalate instead of check is how the duplicate this PR exists to prevent gets created. Fixed twice over: the method gate is now evaluated BEFORE the attempt count, and the count itself tallies only 5xx responses rather than total attempts, so the exhausted-retry message is accurate for idempotent methods too (a GET that saw 429, 500, 500 now reports 2, not 3). 2. The exceptionId bypassed the length/markup guard. `_raise_api_error` read `exceptionId` straight off an untrusted response body and appended it AFTER the MAX_API_ERROR_LENGTH truncation the codebase applies explicitly "to prevent Rich markup injection and excessive output". Human-mode errors render through Rich with markup enabled (OutputFormatter.error), so a bracket-laden or unbounded value from a misbehaving endpoint reached the terminal as markup, and embedded newlines could forge extra log lines (CWE-117). The code comment I wrote asserted the field was "kbagent-authored and short", which is true of the hint and false of the id -- the wrong premise is what hid the hole. `_safe_exception_id` now drops everything outside [A-Za-z0-9._:-] and caps at MAX_EXCEPTION_ID_LENGTH (128; real ids run ~70). Dropping rather than escaping keeps a real id intact and still leaves support a usable handle if a value is partially mangled. The comment now states the actual invariant: only self-bounded strings may be appended past the truncation. Five tests: the 429-then-500 POST hint, 5xx-count accuracy, the length cap, markup/newline/control-char stripping, and a non-string exceptionId.
Review findings addressed (1865e13)Three findings from Devin Review, two of them fixed in code. Recording the third here because it is only visible inside the Devin app, not on this PR — it never made it to a GitHub thread, and it was the most serious of the three. 🔴 Security: server-controlled
|
…dget `main` grew `keboola-expert.md` from 60656 to 61622 bytes while this branch was open (#611, #616). The branch's own copy was fine at 61178, but CI builds the PR MERGE commit -- 61622 + this branch's 522-byte matrix row = 62144, i.e. 144 over the hard 62000 ceiling. All three test jobs failed on it; the branch in isolation passed, which is why it only showed up on the PR. The budget test's own comment rules out raising the ceiling ("split keboola-expert into per-domain specialists rather than raising the ceiling again"), so the row is trimmed to 377 bytes instead: command, version gate, the one fact that changes a decision (recipients live in a separate service, not the flow config), the fallback, and both ways to get it wrong. The flag list and the longer gloss are dropped -- `--help` and `gotchas.md` carry them. The file now sits at 61999 bytes, one under the ceiling. That is not headroom; the next PR touching this file hits the same wall regardless of what it adds. The structural fix the test comment asks for is out of scope here.
…ly (#616 follow-up) (#617) * docs(skill): record why a retried merge-request PUT cannot double-apply (#616 follow-up) The reviewer on #616 flagged that RETRY_SAFE_METHODS treats every PUT as retry-safe, while client/merge_requests.py uses PUT for four action-style transitions (/request-review, /approve, /request-changes, /merge). The worry was that a 5xx raised after the transition committed would be retried and fire it -- and its notifications -- twice. Verified against the keboola/connection source: it cannot. The MR lifecycle is a Symfony Workflow state_machine, so three of the four transitions are refused structurally on a second call (enabled only from their declared `from` place); /approve is the one self-loop and carries AddApprovalGuard instead. Notifications ride workflow.merge_request_lifecycle.completed from inside apply(), inside MergeRequestService's transactional() -- no transition, no notification. No code change. Two things the audit did surface are recorded with it: - A retried PUT reports attempt 2's error, so an operation that succeeded and merely lost its response surfaces as 422/409. That applies to every retried PUT/DELETE, not just merge requests. - bi_rMergeRequestsApprovals has no unique constraint on (mergeRequestId, idAdmin) and hasEnoughApprovals() counts rows rather than distinct admins. Server-side and narrow; filed as keboola/connection#8209. Deliberately not worked around here -- the blanket method rule stays, with no per-call-site retry opt-out. * docs(skill): mark the approvals race as reported-not-proven, link the upstream issue The gotcha stated the mechanism conditionally but never said the duplicate row was not reproduced and the isolation level was not checked -- a caveat the upstream issue does carry, so the two documents disagreed on how firm the finding is. It also had no pointer to keboola/connection#8209, leaving a future reader no handle to re-check it or notice it was fixed.
…85.1 The version bump to 0.86.0 already landed on main (#615), but the release notes it produces were incomplete in two ways. Missing entries. PR #616 (`token list`, plus the retry-policy and exceptionId changes) carried no changelog note at all -- its commit message says "No version bump: this lands in a stack of PRs released as one version. The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm that", and the bump PR did not. `make changelog-check` cannot catch this: it verifies every published GitHub release has an entry, not that every merged PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610 (winget job disabled) were likewise unannounced. All four are added. Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615) without a tag in between, so 0.85.1 exists only as a changelog bucket -- no release, no artifact, nobody running it. `format_whats_new` shows the notes of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would have silently missed those four fixes (Azure ciphertext prefix, the `parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs). The bucket is folded into 0.86.0 verbatim. The same phantom leaked into the agent-facing version gates, which is the worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can install. Retagged to 0.86.0, along with two source comments. Three of the new notes had to lead with a shorter sentence to satisfy `test_newest_release_notes_are_not_truncated` (the headline is the note's first sentence, capped at 160 chars). No behaviour change; documentation and release metadata only.
…85.1 (#619) * chore(release): complete the 0.86.0 changelog and drop the phantom 0.85.1 The version bump to 0.86.0 already landed on main (#615), but the release notes it produces were incomplete in two ways. Missing entries. PR #616 (`token list`, plus the retry-policy and exceptionId changes) carried no changelog note at all -- its commit message says "No version bump: this lands in a stack of PRs released as one version. The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm that", and the bump PR did not. `make changelog-check` cannot catch this: it verifies every published GitHub release has an entry, not that every merged PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610 (winget job disabled) were likewise unannounced. All four are added. Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615) without a tag in between, so 0.85.1 exists only as a changelog bucket -- no release, no artifact, nobody running it. `format_whats_new` shows the notes of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would have silently missed those four fixes (Azure ciphertext prefix, the `parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs). The bucket is folded into 0.86.0 verbatim. The same phantom leaked into the agent-facing version gates, which is the worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can install. Retagged to 0.86.0, along with two source comments. Three of the new notes had to lead with a shorter sentence to satisfy `test_newest_release_notes_are_not_truncated` (the headline is the note's first sentence, capped at 160 chars). No behaviour change; documentation and release metadata only. * fix(changelog): correct the serve route and give every 0.86.0 note a recognised prefix Two findings from Devin's review of this PR, plus one they could not see. The serve route for `token list` was cited as `GET /tokens/{project}`. Both halves are wrong: the router carries `prefix="/token"` (singular) and the operation is registered at `/{project}/list`, so the real path is `GET /token/{project}/list` -- confirmed against the runtime OpenAPI schema, not the source, because that is what a caller actually hits. Worth noting the review's proposed correction (`/tokens/{project}/list`) is itself wrong on the prefix; taking it verbatim would have swapped one 404 for another. `CI:` is not a recognised note prefix. `_PREFIX_STYLES` / `_PREFIX_RE` in commands/changelog.py define the set, the module docstring states the contract, and an unrecognised label renders unhighlighted. Retitled to `Note:`, which also reads better: the winget job being disabled has a user-facing consequence (WinGet users stay on the last published version), so burying it under a dim `Internal:` would understate it. The finding Devin could not report: the four notification notes carried by #615/#618 have no prefix at all. They were outside this PR's diff, so no reviewer looking at the diff would flag them -- but they ship in the same release block and break the same contract, leaving half of v0.86.0 rendering flat. Prefixed `New:` / `Note:` with no change of meaning. Every 0.86.0 note now matches `_PREFIX_RE`, verified by asserting over the live CHANGELOG rather than by reading. Each replacement is written to disk on its own. Running several in one script means a later failed assert discards the earlier successful writes, which is precisely how #618's stale "server-side ?event=" claim survived its own fix pass.
Closes #599.
What this fixes
Issue #599 reported a persistent upstream
500fromPOST /v2/storage/tokenson theeurope-west3.gcpstack, and flagged two client-side gaps it exposed. Both are real. The first turned out to be worse than the reporter could see from the outside.1. Non-idempotent writes were retried on 5xx
BaseHttpClient._do_requestretried on429/500/502/503/504regardless of HTTP method. Reading Keboola'sStorage_Service_Tokens::createTokenshows why that is unsafe for a write:The token row is saved and its secret generated before the block that can throw, and unlike
updateTokendirectly below it,createTokenwraps none of this in a transaction. A 500 raised there leaves a live token behind that the caller never sees — so three attempts could leave three of them. The issue framed the duplicate-mint risk as hypothetical ("if the API ever creates the token and then fails to respond"); that branch is reachable.Changes:
GET/HEAD/OPTIONS/PUT/DELETE(newRETRY_SAFE_METHODS, RFC 9110 idempotent set).POST/PATCH. This second path is the same hazard as the 500 one, only quieter — fixing just the status-code branch would have left it open.retryable: false, so callers do not treat it as a transient blip.This covers all 29
POSTcall sites, not only the token mint —config oauth-urlmints a token through the very same endpoint.2. A 500 gave no next step, and threw away the one useful field
_raise_api_errorpicked up Keboola's genericerror: "Application error."and dropped theexceptionIdsitting next to it — the only handle Keboola support can trace an incident by. That is now surfaced, along with one of two hints:exceptionId.4xx is untouched: the incident hint would be actively misleading there.
This also answers the issue's open question about the escalation channel — the
exceptionIdis what support needs.3.
kbagent token list(the issue comment)The
tokengroup could mint, revoke and rotate but not enumerate, so there was no way to obtain the--token-idthatdelete/refreshrequire without going through the web UI.It ships here rather than separately because it is also the check the new retry behaviour tells you to run: after an unretried mint failure,
token listis how you find out whether the token was created anyway.Secrets are stripped from every row,
--jsonincluded. On a project carrying theforce-decrypted-tokenfeature,tokenToApiResponseembeds each token's live value in the listing; reproducing that would break the group's "revealed once, at mint" contract for every token in the project at once. Stripped at the service layer and again in the SDK facade.Full surface:
KeboolaClient.list_tokens(),TokenService.list_tokens(), the CLI command,GET /token/{project}/listonserve,token.list: readin the permission registry, andClient.list_tokens() -> list[TokenListEntryResult]on the SDK.Not in scope
The upstream 500 itself. Worth recording what I found while triaging it, since it narrows the search for whoever picks it up:
env:com-keboola-gcp-europe-west3) hasPOST https://connection.europe-west3.gcp.keboola.com/v2/storage/tokens "HTTP/1.1 200 OK"frommcp-serverat 2026-08-18 08:58:43 UTC — 14 minutes after the issue was filed.canManageBuckets: true, which is exactly the flag that skips thegetAllProjectBucketIdsMap/BucketPermissions::evaluateblock. kbagent never sets it, so it always enters that block.TokenCreateProcessor::process) aStorage_Service_TokensExceptionfrom that block is not caught — unlike the manage-token path (ProjectTokensService), which converts it into a validation error. An uncaught exception is exactly a generic 500.I could not find the reporter's failing request in the logs, so this is a hypothesis from reading the source, not a confirmed cause.
Testing
tests/test_http_base.pycovering the method gate (POST/PATCH not retried on 5xx; PUT/DELETE still are; 429 retried on POST), the timeout split, and all three message shapes.TestListTokensintest_client_device_enrollment.py,test_token_service.py,test_token_cli.py,test_lib_device_enrollment.py— including that no layer lets a secret through.token listinserted into the existingcreate -> refresh -> deleteflow, asserting the minted token appears and no row carries a secret.make checkgreen.Note for the release PR
No version bump here — this is one of a stack of PRs going out as a single release. The
(since v0.86.0)tags ingotchas.md,commands-reference.mdanddocs/sdk.mdassume0.86.0; the bump PR must confirm that number and adjust them if it differs.keboola-expert.mdis now 61257 bytes against the 62000 cap — roughly 740 bytes of headroom left.