Skip to content

spike: evaluate the Core SDK against the dns command group - #42

Draft
patramsey wants to merge 4 commits into
mainfrom
spike/core-sdk-dns
Draft

spike: evaluate the Core SDK against the dns command group#42
patramsey wants to merge 4 commits into
mainfrom
spike/core-sdk-dns

Conversation

@patramsey

Copy link
Copy Markdown
Owner

Draft, and not intended to merge as-is. This is the dns spike from #40.
internal/sdkspike is not imported by cmd/, nothing existing is changed, and
the package should be deleted once the decision is made either way. It is on a
branch so the evidence is reviewable rather than described.

Ported the dns read and read-modify-write paths onto
github.com/namedotcom/core-api-go v1.33.2, wired the way the evaluation
recommends: the SDK for types, our own transport for rate limiting, retry
policy, and the deadline guard from #41.

The headline result is a defect, not a design question

option.WithoutRetries() is silently ignored at client scope.

internal/retrier.go's NewRetrier reads only attempts out of the options it
is handed and discards disabled:

func NewRetrier(opts ...RetryOption) *Retrier {
	options := new(retryOptions)
	for _, opt := range opts { opt(options) }
	attempts := uint(defaultRetryAttempts)
	if options.attempts > 0 { attempts = options.attempts }
	return &Retrier{attempts: attempts}   // options.disabled dropped here
}

Run honours disabled only from per-call options, and each generated method
rebuilds its options from scratch (options := core.NewRequestOptions(opts...)),
so the client-scoped value never reaches it. Measured against a 500:

wiring requests
WithoutRetries() on the client 2
WithoutRetries() per call 1
no option 2

This is a safety setting, and client scope is where a safety setting most needs
to hold. The fallback — carrying the option on all ~53 call sites — works, but
anything added later that forgets it silently regains the behaviour, with no
signal.

The client-scope test asserts the defect, so the day the SDK fixes it the test
fails and tells us the workaround can be dropped.

What that option is guarding

Two claims from the issue body were wrong

The generated-type gotchas do not disappear. They come from the spec, not
from oapi-codegen, so they survive:

today SDK
record TTL int64 value int64 value
update body TTL *int64 *int64
record Type *string *string
update body Type named enum, cast required named enum, cast required

Plus a new one: Host is string on the create body and *string on the
update body.

cmdutil.NextPage does not port unchanged. The SDK reports nextPage and
lastPage as *int where the generated client used *int32, so every
paginated walk needs the same re-typing. The spike carries a re-typed copy to
show the shape of it.

What does hold

  • Coverage: all 53 operations the CLI calls (eight were Fern renames).
  • Auth: byte-identical to what client.go builds today — asserted.
  • Our transport survives: asserted explicitly with a counting
    RoundTripper, because it is the assumption the whole recommendation rests on
    and it depends on a fallback in caller.Call (client := c.client; if params.Client != nil) that the retry flag conspicuously did not get.

What is genuinely better

CreateRecord returns *Record rather than a response needing Decode();
DeleteRecord returns just error; DomainName/ID move into the request
struct instead of positional arguments. The RMW merge itself is slightly
shorter. Modest, but real.

Recommendation

The prize is still the ~39,800 lines of generated client, vendored spec, and
Python preprocessor. But the retry layer has to be neutralised per call, not per
client, until the SDK is fixed — and that is a standing hazard rather than a
one-time cost.

Worth raising WithoutRetries() upstream before committing. If it is fixed, the
migration gets materially safer; if it is not, the per-call discipline needs a
lint rule or a wrapper that makes forgetting it impossible.

Run go test ./internal/sdkspike/ -v for the measurements.

Throwaway evaluation for #40. internal/sdkspike is not imported by cmd/ and
is meant to be deleted once the decision is made either way. Nothing existing
is changed; the generated client, the spec, and the preprocessor all stay.

The package ports the dns read and read-modify-write paths onto
github.com/namedotcom/core-api-go v1.33.2, wired the way the evaluation
recommends — the SDK for types, our own transport for rate limiting, retry
policy, and the deadline guard from #41 — and the tests answer the three
questions the spike was scoped to settle.

The headline result is a defect, not a design question. option.WithoutRetries()
is silently ignored at client scope: internal/retrier.go's NewRetrier reads
only `attempts` out of the options it is handed and discards `disabled`, so a
client built with the option still gets the default two attempts. Run() honours
`disabled` only from per-call options. Measured: two requests against a 500
with the option set on the client, one with it set per call. That matters
because it is a safety setting, and client scope is where a safety setting most
needs to hold — the fallback is carrying the option on all ~53 call sites,
where anything added later that forgets it silently regains the behaviour.

The behaviour it guards is real: the SDK retries a POST on a 5xx (shouldRetry
dispatches on status code alone, no method check), which is what idempotent()
refuses to do, and the API honours idempotency keys on only five operations.
Its backoff also sleeps with a bare time.Sleep — a call with a 100ms deadline
took 2s against a Retry-After of 2 — so --timeout would stop being enforceable
during backoff.

Two claims from the issue body are corrected by the port. The generated-type
gotchas do NOT disappear: Record.TTL is a value while the update body's is a
pointer, and Record.Type is a *string while the body's is a named enum needing
a cast. Both come from the spec, not from oapi-codegen, so both survive. Host
is additionally a string on the create body and a *string on the update body.
And cmdutil.NextPage does not port unchanged — the SDK reports page numbers as
*int where the generated client used *int32, so every paginated walk needs the
same re-typing.

What does hold: coverage of all 53 operations the CLI calls, auth
byte-identical to what client.go builds today, and the http.Client we supply
surviving into every request — the last one asserted explicitly, since it is
the assumption the whole recommendation rests on and it depends on a fallback
in caller.Call that the retry flag did not get.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.67568% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/sdkspike/spike.go 75.6% 9 Missing and 9 partials ⚠️

📢 Thoughts on this report? Let us know!

Saves the two unfiled bug reports under docs/upstream/ so the analysis is not
lost between writing it and opening the issues, and makes internal/sdkspike
safe to reason about in the meantime.

The two defects need two separate workarounds, which a measurement was needed
to establish. Disabling retries does NOT skip the sleep: Retrier.run sleeps
BEFORE it checks the attempt counter, so a call with retries off still waits
out the server's Retry-After and only then declines to retry. Against a 429
carrying "Retry-After: 30" that is thirty seconds of dead time for a single
request. Assuming one workaround covered both would have left that in place.

Defect 1, the ignored client-scoped WithoutRetries, is worked around by
appending the option to every call — and by making that impossible to forget.
Client keeps *sdk.Namecom unexported and exposes only wrapped methods, so
there is no way to issue a request that skips the option without editing
workaround.go. Convention would not survive ~53 call sites; forgetting it
silently restores POST-on-5xx retries against endpoints that do not honour
idempotency keys.

Defect 2, the context-ignoring backoff, is worked around by deleting the
Retry-After header from responses on their way back to the SDK. That is only
safe because of where it sits: our transport has already read the header, made
its retry decision, and either waited on a context-aware timer or declined to
wait at all, so the header has no remaining consumer in-process. Removing it
caps the SDK's dead sleep at its own minRetryDelay. Measured: 30s to 1s, with
*core.APIError still carrying status 429 and the body. The strip is scoped to
the client handed to the SDK and does not touch the caller's own, so raw
response readers still see what the server sent — asserted, and the assertion
fails if the wrapper mutates the client passed in.

Residue worth stating plainly: roughly one second of dead sleep remains on
every terminal 429 or 5xx, and it cannot be removed from outside the SDK.

Each workaround was checked by removing it and confirming its test fails.
The workarounds landed with their reasoning in code comments, which is the
right place for why a line exists but the wrong place for "here is the whole
arrangement and here is how it ends". docs/upstream/workarounds.md collects it:
what each one costs, measured; why the two are not one problem; why deleting a
response header is defensible here; and the removal steps for each defect.

The part worth having written down is that the repo will tell us when upstream
fixes this rather than us having to remember to check. Two tests assert the
DEFECTS rather than the workarounds, so they fail when the defects go away, with
messages that say what to drop. Both were verified against a locally patched
copy of v1.33.2 carrying the fixes the drafts suggest: they fire, and the
workaround tests keep passing, so a dependency bump surfaces exactly those two
and nothing else.

Also states the residue plainly, since it is easy to lose in a success story:
roughly a second of dead sleep remains on every terminal 429 or 5xx, it is the
SDK's own minRetryDelay, and it cannot be removed from outside the package.
Reverses the client-side workarounds added in a0c1533. The goal is to document
the defects well enough to file them upstream, not to carry code that papers
over them — a wrapper enforcing a per-call option across ~53 methods is a real
maintenance obligation, and taking it on before the reports have even been
filed is premature.

internal/sdkspike goes back to its state at 72e1c7c: a plain evaluation of the
SDK against the dns command group, with tests that demonstrate the defects and
fail if either is ever fixed.

What the workarounds proved is kept, in docs/upstream/core-api-go-mitigations.md
and marked plainly as evaluated-not-adopted. Both mitigations work and the
numbers are recorded — per-call WithoutRetries takes a POST against a 5xx from
two requests to one; stripping Retry-After takes a 429 carrying "Retry-After:
30" from 30s to about 1s, keeping the status either way. So does the finding
that made two mitigations necessary rather than one: Retrier.run sleeps before
it checks the attempt counter, so disabling retries does not skip the wait.

That belongs on record whichever way #40 goes. If upstream fixes the defects it
is history; if the migration proceeds first, mitigation 1 is not optional
because it guards the money endpoints, and the cost of it is the thing to weigh.
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