fix: idempotency key scoping, profile resolution, and unbounded paging - #41
Merged
Conversation
Five bugs found by probing the binary against a local stub rather than by reading. Each fix has a regression test, and each test was checked by reverting the fix and confirming it fails. The idempotency one is the reason this branch exists. A key identifies an operation, but root.go minted one per invocation and pinned it on the context, so every write in a command shared it. `dns import` posts once per record; a three-record file went out as three different bodies under one key. If the API enforces keys on that endpoint as its own docs describe, record 1 is created and records 2..N return record 1's response, while the CLI counts each as a success and prints "Imported N record(s)" — silent data loss reported as completion. The editor now mints a key per outgoing write. Retries are unaffected: the editor runs once where the request is built and retryTransport replays that same request with its headers already set. Profile resolution fell through to the empty string when a config file had no top-level `default:` key, so f.Profiles[""] returned the zero value and the CLI reported "no credentials configured" — recommending `auth login`, which would have overwritten the working profile that `config list-profiles` was printing one command earlier. auth login writes the key, so only hand-edited files were affected, which is the only way to configure token_cmd. Paginated walks trusted the server to stop saying "there is more". Against one that kept answering nextPage:2, `dns list --all` never returned — 20 seconds of hammering at the full rate limit before it was killed. domain list was immune because it bounds on lastPage; nothing else did, including record-ID completion, where the symptom is a hung shell. cmdutil.NextPage now gates all ten walks on the page advancing and staying within lastPage. A 429 with Retry-After: 600 was slept on until the client timeout fired, converting an answer the server had already given into "context deadline exceeded ... while awaiting headers" at exit 1. The response is now returned when the wait cannot fit the budget, so it stays a 429 at exit 5, and APIError carries Retry-After so the hint can name it instead of advising the user to "wait a moment". Non-JSON error bodies were the error message verbatim, bounded only by the 1 MiB read limit: an nginx 502 page rendered as one 20 KB line. Also corrects CLAUDE.md, which claimed POSTs are retried when an idempotency key is set. They are not, deliberately, and transport_test.go pins it.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
parseError was the one piece of new logic in this branch without direct coverage. Nothing else populates APIError.RetryAfter, and UserHint falls back to the generic "wait a moment" wording when it is zero — so a regression here would produce a hint that still reads correctly while having lost the number the API supplied. Covers the delta-seconds form, an absent header, and the HTTP-date form the parser deliberately does not accept.
Codecov flagged the patch, and most of what it listed is call-site plumbing
not worth a harness. Four entries were not: email, transfer, and vanity-ns
had no multi-page test at all, and record-ID completion had no test at all.
That matters here specifically. Ten pagination loops were rewritten to route
through cmdutil.NextPage, and nine of them by a regex. The continuation line
is exactly what a mechanical rewrite gets wrong, and with no multi-page
fixture a loop that stopped after page 1 — or never advanced — would have
looked green. Verified by mutation: pinning `page = 1` in each of the four
now fails its test.
Each gets both halves of the behaviour: a two-page walk asserting every item
arrives and that the pages were not aliased, and a server whose nextPage
never advances, asserting the walk stops after two requests. For completion
the second case is the interesting one, since the symptom there is a shell
hung mid-tab with nothing on screen to explain it.
Also covers the deadline check on the transport's network-error path, the
sibling of the one already covered on the status path.
One test case here was wrong on the first pass and is worth noting rather
than quietly fixing: it called CompleteRecordIDs with a bare &cobra.Command{}
to check the no-client path, which panicked on a nil context. Cobra never
produces that — ExecuteC always sets one — so the panic was the test's fault,
not a bug. The case now uses a context with no client on it, which is the
state root.go actually leaves for __complete when credentials are absent.
This was referenced Aug 20, 2026
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.
Five bugs, found by probing the built binary against a local stub rather than by
reading. Each has a regression test, and each test was verified by reverting its
fix and confirming the test fails.
1.
dns importsent one idempotency key for every recordA key identifies an operation, but
root.gominted one per invocation andpinned it on the context, so every write in a command shared it. Import posts
once per record — three different bodies, one key:
If the API enforces keys on that endpoint as its own docs describe — "reusing
the same key returns the original result instead of repeating the operation" —
a 50-record zone file creates record 1, gets record 1's response 49 more times,
and prints
Imported 50 record(s). Silent data loss reported as completion.api.Decode(resp, nil)discards the response, so the duplicate IDs are notnoticed either.
The editor now mints a key per outgoing write. Retries are unaffected: it runs
once where the request is built, and
retryTransportreplays that same*http.Requestwith its headers already set, so every attempt at one operationcarries one key.
--idempotency-keystill pins every write in an invocation toone value — that is what makes re-running a failed command collapse onto the
original — and its help text now says so.
Note: the client behaviour is confirmed; whether name.com actually dedupes
on this endpoint is not, since verifying it means creating real records. The
scoping is wrong either way.
2. Credentials that exist reported as missing
firstNonEmpty(flag, env, f.Default)resolved to""when a file carried notop-level
default:key, andf.Profiles[""]is the zero value:The recommended fix would have overwritten the working profile.
auth loginwrites the key, so only hand-edited files were affected — which is the only way
to configure
token_cmd.A profile named
defaultis now used without the key, as is a lone profileunder any name. Two or more with no default stays an error, but one that names
them:
3. Seven list commands could page forever
The only stopping condition was
nextPage == nil || *nextPage == 0, whichtrusts the server to eventually stop saying "there is more". Against a stub
that kept answering
nextPage: 2,dns list --allnever returned — stillgoing after 20 seconds at the full 10 req/s rate limit.
domain listwas immune because it bounds its walk onlastPage. Nothing elsedid:
order,dns,email,url,vanity-ns,transfer,contact,namecom status, andCompleteRecordIDs— where the symptom is a hung shellcompletion. All ten walks now go through
cmdutil.NextPage, which requires thepage number to advance and to stay within
lastPagewhen the API reports one.4. A 429 with a long
Retry-Afterwas swallowedBefore — 30 seconds of silence, then:
Exit 1, a transport error, discarding the rate-limit answer the server had
already given. After — immediate, exit 5:
A wait that cannot fit the remaining deadline is no longer taken; the response
is returned instead. A server-supplied wait is also clamped to 30s, matching
the cap computed backoff has always had.
APIErrorgainsRetryAfterso thehint can name the delay rather than advising the user to "wait a moment".
5. Non-JSON error bodies echoed verbatim
An nginx 502 page became the error message: one 20,745-byte line, in the
terminal and inside the JSON error envelope alike.
parseErrorcapped the readat 1 MiB but nothing capped the message. Now collapsed to one line and
truncated to 400 characters with the dropped byte count disclosed — 930 bytes
total for the same response.
Docs
CLAUDE.md:66claimedtransport.goretries a POST whenX-Idempotency-Keyis set. It never has —
idempotent()covers GET/HEAD/PUT/DELETE only, andtransport_test.go:149pins that a key does not make a POST retryable on5xx. This matters for #40, where the claim had already been repeated; corrected
there too.
--timeoutis also now described as the total budget for one callincluding retries, which is what
http.Client.Timeouthas always meant.Tests
New:
TestIdempotencyKeyIsPerOperation,TestIdempotencyKeyPinnedAcrossOperations,TestIdempotencyKeyAbsentOnReads,TestResolveImpliedDefault,TestNextPage,TestRetryAfterDoesNotOutliveDeadline,TestRetryAfterHonouredWhenItFits,TestRetryAfterClamped,TestSummarizeBody,TestRetryAfterHint.TestContextCancelStopsRetryis restructured rather than merely updated. Itsold assertion — that an unfittable wait produces a context error — is the exact
behaviour #4 fixes, so it now covers both paths: an unfittable wait returns the
response, and a cancellation during an eligible backoff still abandons it.
The elapsed-time bound the original comment describes is preserved, since it is
what actually holds
sleep()to account.make test,make lint, andmake buildpass.