spike: evaluate the Core SDK against the dns command group - #42
Draft
patramsey wants to merge 4 commits into
Draft
spike: evaluate the Core SDK against the dns command group#42patramsey wants to merge 4 commits into
patramsey wants to merge 4 commits into
Conversation
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 Report❌ Patch coverage is
📢 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.
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.
Draft, and not intended to merge as-is. This is the
dnsspike from #40.internal/sdkspikeis not imported bycmd/, nothing existing is changed, andthe 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
dnsread and read-modify-write paths ontogithub.com/namedotcom/core-api-go v1.33.2, wired the way the evaluationrecommends: 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'sNewRetrierreads onlyattemptsout of the options itis handed and discards
disabled:Runhonoursdisabledonly from per-call options, and each generated methodrebuilds its options from scratch (
options := core.NewRequestOptions(opts...)),so the client-scoped value never reaches it. Measured against a 500:
WithoutRetries()on the clientWithoutRetries()per callThis 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
shouldRetrydispatches on status code alone, nomethod check — exactly what
idempotent()refuses to do. Measured: the SDKsent a
CreateRecordPOST twice against a 500. The API honours idempotencykeys on only five operations, and record creation is not one (confirmed in
sandbox, see Evaluate replacing the vendored spec + codegen with the official name.com Core SDK #40).
internal/retrier.go:146is a baretime.Sleep(delay). A call with a 100ms deadline took 2s against aRetry-After: 2.--timeoutstops being enforceable during backoff, and fix: idempotency key scoping, profile resolution, and unbounded paging #41's"return the 429 rather than sleep past the deadline" behaviour is undone.
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:
int64valueint64value*int64*int64*string*stringPlus a new one:
Hostisstringon the create body and*stringon theupdate body.
cmdutil.NextPagedoes not port unchanged. The SDK reportsnextPageandlastPageas*intwhere the generated client used*int32, so everypaginated walk needs the same re-typing. The spike carries a re-typed copy to
show the shape of it.
What does hold
client.gobuilds today — asserted.RoundTripper, because it is the assumption the whole recommendation rests onand 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
CreateRecordreturns*Recordrather than a response needingDecode();DeleteRecordreturns justerror;DomainName/IDmove into the requeststruct 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, themigration 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/ -vfor the measurements.