Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/upstream/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Upstream issue drafts

Bug reports written against dependencies. They live here so
the analysis is not lost between the day it is done and the day someone opens
the issue — the work of reproducing a defect and verifying a fix is worth more
than the few minutes it takes to paste it into a tracker.

Each carries a banner naming the filed issue once it is filed. The body stays
put rather than being replaced by a link — the reproduction and the verified fix
are the expensive part, and they should survive the tracker.

| Draft | Against | Status |
|---|---|---|
| [`core-api-go-withoutretries-ignored.md`](core-api-go-withoutretries-ignored.md) | `namedotcom/core-api-go` v1.33.2 | [filed as #3](https://github.com/namedotcom/core-api-go/issues/3) |
| [`core-api-go-backoff-ignores-context.md`](core-api-go-backoff-ignores-context.md) | `namedotcom/core-api-go` v1.33.2 | [filed as #4](https://github.com/namedotcom/core-api-go/issues/4) |

Neither is worked around in this repository. What a mitigation would look like,
and what each was measured to cost, is recorded in
[`core-api-go-mitigations.md`](core-api-go-mitigations.md) for the #40 decision.
137 changes: 137 additions & 0 deletions docs/upstream/core-api-go-backoff-ignores-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Retry backoff ignores the request context

**Filed 2026-08-20 as [namedotcom/core-api-go#4](https://github.com/namedotcom/core-api-go/issues/4).** Kept here so the reproduction stays with the repository that produced it.


**Version:** v1.33.2 · **Go:** 1.26.6

## Summary

`Retrier.run` waits between attempts with a bare `time.Sleep`. It does not
select on `ctx.Done()`, so a cancelled or expired context does not interrupt the
wait. A call therefore outlives its own deadline, and `context.WithTimeout` —
along with anything built on it, such as a CLI `--timeout` flag — stops bounding
the call once a retry begins.

## Root cause

`internal/retrier.go:146`:

```go
if r.shouldRetry(response) {
defer func() { _ = response.Body.Close() }()

delay, err := r.retryDelay(response, retryAttempt)
if err != nil {
return nil, err
}

time.Sleep(delay) // not context-aware
...
}
```

The context is checked before each attempt (`internal/retrier.go:120`), so
cancellation is noticed *eventually* — but only after the full sleep has already
elapsed. With `maxRetryDelay = 60s` and `Retry-After` honoured up to that cap, a
single 429 can hold the call for a minute past its deadline.

## Reproduction

```go
package main

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"

coreapigo "github.com/namedotcom/core-api-go"
sdk "github.com/namedotcom/core-api-go/client"
"github.com/namedotcom/core-api-go/option"
)

func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "2")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"message":"slow down"}`))
}))
defer srv.Close()

c := sdk.NewNamecom(
option.WithBaseURL(srv.URL),
option.WithBasicAuth("u", "t"),
)

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

start := time.Now()
_, err := c.DNS.ListRecords(ctx, &coreapigo.ListRecordsRequest{DomainName: "example.com"})
fmt.Printf("elapsed=%s err=%v\n", time.Since(start).Round(time.Millisecond), err)
}
```

### Actual

```
elapsed=2.001s err=context deadline exceeded
```

### Expected

Roughly `elapsed=100ms`, the deadline the caller set.

## Consequences

- **A deadline stops meaning anything during backoff.** Anything mapping a
user-facing timeout onto `context.WithTimeout` silently loses control of the
call.
- **Cancellation is not honoured promptly.** `ctrl-C` wired to `cancel()` leaves
the process sitting in `time.Sleep` for up to `maxRetryDelay`.
- **The error loses its cause.** The call above reports
`context deadline exceeded` when what actually happened is a 429 the server
already answered clearly. A caller that wants to surface "rate limited, retry
after 2s" cannot, because the useful response was discarded in favour of a
timeout produced by the SDK's own sleep.

## Suggested fix

Make the wait cancellable:

```go
- time.Sleep(delay)
+ timer := time.NewTimer(delay)
+ select {
+ case <-request.Context().Done():
+ timer.Stop()
+ return nil, request.Context().Err()
+ case <-timer.C:
+ }
```

**Verified.** With that patch applied to a local copy of v1.33.2, the
reproduction above prints `elapsed=101ms err=context deadline exceeded`
instead of `elapsed=2.001s`. `go test ./internal/...` still passes against the
patched copy.

Optionally, and separately: when the remaining time on the deadline is shorter
than `delay`, returning the response rather than sleeping at all preserves the
server's answer instead of converting it into a timeout. That turns the example
above into a 429 the caller can act on.

## Note on where the fix belongs

`.fernignore` exempts only `.fern/replay.lock`, `.fern/replay.yml`, and
`.gitattributes`, so `internal/retrier.go` is regenerated and a patch here would
not survive the next Fern run. Flagging it in case this needs to go to the Fern
Go generator template instead.

## Related

Filed separately from the `option.WithoutRetries()` issue — different root
cause, different fix — though both are in `internal/retrier.go` and both affect
callers trying to bound or opt out of retry behaviour.
112 changes: 112 additions & 0 deletions docs/upstream/core-api-go-mitigations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Mitigations for the `core-api-go` retry defects — evaluated, not adopted

**Nothing in this repository works around these defects.** This file records
what a mitigation would look like and what it was measured to cost, so the
information is available to the #40 decision and to the upstream reports
without anyone having to rediscover it.

The defects themselves are filed upstream and drafted in this directory:

| # | Defect | Upstream |
|---|---|---|
| 1 | `option.WithoutRetries()` is silently ignored at client scope | [#3](https://github.com/namedotcom/core-api-go/issues/3) · [draft](core-api-go-withoutretries-ignored.md) |
| 2 | Retry backoff sleeps with a bare `time.Sleep`, ignoring the request context | [#4](https://github.com/namedotcom/core-api-go/issues/4) · [draft](core-api-go-backoff-ignores-context.md) |

## They are not one problem

Worth recording, because the obvious simplification is wrong and the reasoning
is not visible from the API surface.

Disabling retries does **not** skip the sleep. `Retrier.run` sleeps *before* it
checks the attempt counter:

```go
if r.shouldRetry(response) {
delay, _ := r.retryDelay(response, retryAttempt)
time.Sleep(delay) // happens first
return r.run(..., retryAttempt+1, ...) // counter checked on entry here
}
```

So a call with retries disabled still issues one request, waits out the server's
full `Retry-After`, and only then declines to retry. Measured against a 429
carrying `Retry-After: 30`: one request, thirty seconds.

Any mitigation therefore needs two parts. Treating defect 2 as a consequence of
defect 1 leaves the wait in place, where it presents as a hang rather than
as a bug.

## Mitigation for defect 1 — pass the option per call

`option.WithoutRetries()` works per call; only the client-scoped form is
ignored. Measured against a 500:

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

**Cost.** The option has to be repeated at every call site, and forgetting it
silently restores POST-on-5xx retries against endpoints that do not honour
idempotency keys — `CreateDomain`, `RenewDomain`, `ProcessRefund`. Convention
will not hold across ~53 call sites, so it would have to be enforced by
construction: a wrapper type keeping the SDK client unexported and exposing only
methods that append the option. That is roughly 53 hand-written pass-through
methods to maintain, and one more for every operation added upstream.

This is the real cost to weigh in #40 — not the option itself, which is one line,
but the obligation to carry it across the whole API surface for as long as the
defect stands.

## Mitigation for defect 2 — strip `Retry-After` before the SDK sees it

Deleting the header from responses on their way back to the SDK makes
`retryDelay` fall through to its own `minRetryDelay`. Measured against a 429
carrying `Retry-After: 30`, with retries already disabled per call:

| response header | elapsed | requests | error |
|---|---|---|---|
| `Retry-After: 30` intact | 30s | 1 | `*core.APIError`, status 429 |
| `Retry-After` stripped | ~1s | 1 | `*core.APIError`, status 429 |

**Why it would be safe.** By the time a response passes back through our
transport, `retryTransport` has already read `Retry-After`, made its retry
decision, and either waited on a context-aware timer or declined to wait at all
(the deadline guard from #41). The header has no remaining consumer in-process.
It would have to be scoped to the client handed to the SDK and not to the shared
transport, so `namecom api` and anything else reading raw responses still sees
what the server sent.

**Cost.** Roughly one second of dead sleep would remain on every terminal 429 or
5xx — the SDK's own `minRetryDelay`, unreachable from outside the package. No
extra request and no lost status, but latency on the error path that does not
exist on the current generated client.

## What this means for #40

Both mitigations work and were measured. Neither is adopted, and the preference
is that upstream fixes the defects instead — the suggested patches in both
drafts were verified against a local copy of v1.33.2 and are small. Both are now
filed as [#3](https://github.com/namedotcom/core-api-go/issues/3) and
[#4](https://github.com/namedotcom/core-api-go/issues/4).

If the migration proceeds before a fix lands, mitigation 1 is not optional: it
guards the money endpoints. Mitigation 2 is a latency question and could be
skipped, at the cost of a call occasionally appearing to hang for up to the
SDK's 60s `maxRetryDelay`.

## Reproducing the measurements

The two issue drafts each carry a self-contained `main.go` that reproduces the
defect and prints the numbers above. Both were run against v1.33.2; the
suggested fixes were then applied to a local copy and the programs re-run to
confirm the fixes work and that `go test ./internal/...` still passes upstream.

The defect-demonstration tests in `internal/sdkspike` cover the same ground as
part of the #40 spike, and are written to fail if a defect is ever fixed:

| Test | Fires when |
|---|---|
| `TestWithoutRetriesHoldsAtClientScope/client-scoped_WithoutRetries_is_silently_ignored` | defect 1 is fixed |
| `TestSDKBackoffIgnoresContext` | defect 2 is fixed |
Loading