Skip to content

fix: idempotency key scoping, profile resolution, and unbounded paging - #41

Merged
patramsey merged 3 commits into
mainfrom
fix/api-and-config-bugs
Aug 20, 2026
Merged

fix: idempotency key scoping, profile resolution, and unbounded paging#41
patramsey merged 3 commits into
mainfrom
fix/api-and-config-bugs

Conversation

@patramsey

Copy link
Copy Markdown
Owner

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 import sent one idempotency key for every record

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. Import posts
once per record — three different bodies, one key:

POST key=a160be44-…e429aef body={"answer":"1.1.1.1","host":"a",…}
POST key=a160be44-…e429aef body={"answer":"2.2.2.2","host":"b",…}
POST key=a160be44-…e429aef body={"answer":"3.3.3.3","host":"c",…}

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 not
noticed either.

The editor now mints a key per outgoing write. Retries are unaffected: it runs
once where the request is built, and retryTransport replays that same
*http.Request with its headers already set, so every attempt at one operation
carries one key. --idempotency-key still pins every write in an invocation to
one 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 no
top-level default: key, and f.Profiles[""] is the zero value:

$ namecom config list-profiles     # {"name": "default", "username": "alice", …}
$ namecom auth status
error: no credentials configured (… or run 'namecom auth login')   # exit 3

The recommended fix would have overwritten the working profile. auth login
writes the key, so only hand-edited files were affected — which is the only way
to configure token_cmd.

A profile named default is now used without the key, as is a lone profile
under any name. Two or more with no default stays an error, but one that names
them:

no credentials configured: 2 profiles exist (home, work) but none is the
default — pass --profile, set NAMECOM_PROFILE, or run 'namecom config use <profile>'

3. Seven list commands could page forever

The only stopping condition was nextPage == nil || *nextPage == 0, which
trusts the server to eventually stop saying "there is more". Against a stub
that kept answering nextPage: 2, dns list --all never returned — still
going after 20 seconds at the full 10 req/s rate limit.

domain list was immune because it bounds its walk on lastPage. Nothing else
did: order, dns, email, url, vanity-ns, transfer, contact,
namecom status, and CompleteRecordIDs — where the symptom is a hung shell
completion. All ten walks now go through cmdutil.NextPage, which requires the
page number to advance and to stay within lastPage when the API reports one.

4. A 429 with a long Retry-After was swallowed

Before — 30 seconds of silence, then:

error: Get "http://…": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Exit 1, a transport error, discarding the rate-limit answer the server had
already given. After — immediate, exit 5:

error: slow down
  hint: rate limited — the API asked to wait 10m0s before retrying

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. APIError gains RetryAfter so the
hint 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. parseError capped the read
at 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:66 claimed transport.go retries a POST when X-Idempotency-Key
is set. It never has — idempotent() covers GET/HEAD/PUT/DELETE only, and
transport_test.go:149 pins that a key does not make a POST retryable on
5xx. This matters for #40, where the claim had already been repeated; corrected
there too. --timeout is also now described as the total budget for one call
including retries, which is what http.Client.Timeout has always meant.

Tests

New: TestIdempotencyKeyIsPerOperation, TestIdempotencyKeyPinnedAcrossOperations,
TestIdempotencyKeyAbsentOnReads, TestResolveImpliedDefault, TestNextPage,
TestRetryAfterDoesNotOutliveDeadline, TestRetryAfterHonouredWhenItFits,
TestRetryAfterClamped, TestSummarizeBody, TestRetryAfterHint.

TestContextCancelStopsRetry is restructured rather than merely updated. Its
old 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, and make build pass.

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

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/root.go 33.3% 4 Missing ⚠️
cmd/status.go 77.7% 2 Missing ⚠️

📢 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.
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