Skip to content

release: v0.2.0 - permanent DNS failures are no longer retried - #6

Merged
oswaldom-code merged 5 commits into
mainfrom
develop
Aug 5, 2026
Merged

release: v0.2.0 - permanent DNS failures are no longer retried#6
oswaldom-code merged 5 commits into
mainfrom
develop

Conversation

@oswaldom-code

@oswaldom-code oswaldom-code commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

Cuts v0.2.0. Two changes: a correctness fix in the error taxonomy, and a redundant allocation removed from the Timeout middleware.

Minor rather than patch: the release adds public API and changes observable behavior. A caller who relied on NXDOMAIN being retried, or on IsDNS matching only the transient kind, will see a difference without touching their code.


Fix: permanent DNS failures are no longer retried

ErrKindDNS was retryable unconditionally, so DefaultIsRetryable retried NXDOMAIN — permanent by definition. A misspelled or decommissioned hostname burned the whole attempt budget plus the full backoff schedule on an outcome that was already decided at the first lookup. The resolver was reporting net.DNSError.IsNotFound == true the entire time; the signal was simply never read.

Reproduction against .invalid (reserved by RFC 2606, can never resolve), MaxAttempts: 4, constant backoff 100 ms:

before after
Kind dns dns_not_found
Kind.IsRetryable() true false
transport attempts 4 1
wall clock 304 ms 0 s

Why the kind was split rather than the predicate patched

classifyError now consults IsNotFound and returns a distinct ErrKindDNSNotFound, excluded from IsRetryable.

retry.go is untouched. DefaultIsRetryable already delegates to Kind.IsRetryable(), so fixing the classification fixes all three public paths at once — DefaultIsRetryable, the package-level IsRetryable(err), and Classify(err).Kind.IsRetryable(). Special-casing the error inside the retry predicate would have left the other two still reporting true for NXDOMAIN, and the taxonomy is a headline feature of this library, not an advisory hint.

ErrKindDNS is deliberately not flipped wholesale to non-retryable: DNS timeouts already classify as ErrKindTimeout (the net.Error.Timeout() check runs first), so what remains under ErrKindDNS is a genuine mix. SERVFAIL and friends (IsTemporary) stay retryable.

Same approach as the AWS SDK for Go v2 (aws/retry/retryable_error.go) and gRPC-Go's DNS resolver.

API

  • Added ErrKindDNSNotFound, appended last in the ErrorKind block so the numeric values shipped in 0.1.0 do not shift.
  • Added IsDNSNotFound(err).
  • Changed IsDNS(err) now matches both DNS kinds. An NXDOMAIN is still a DNS failure — making IsDNS false for it would silently break existing callers on upgrade, in the worst possible direction.

Perf: Timeout attaches its context without cloning

timeout.go deep-copied the request when all it needed was to attach a new context. The copy was redundant: Do already clones the caller's request before the chain runs, so mid-chain independence protects nobody — it only duplicated the struct, the URL and the header map on every request.

- req = req.Clone(ctx)
+ req = req.WithContext(ctx)

Measured, min of 5, linux/amd64, Go 1.24.1:

Benchmark before after
AllMiddleware 13 allocs / 1589 B 11 allocs / 1304 B
WithTimeout 12 allocs 10 allocs
comparative overhead tier 12 allocs 10 allocs
e2e tier 76 allocs 74 allocs

Do's clone is now the sole shield for the caller's request and nothing tested it, so this adds TestClient_Do_ShieldsCallerRequestFromMiddleware: a middleware that sets a header must reach the transport without the header leaking back onto the caller's request.

benchmarks/REPORT.md regenerated via make report; README benchmark tables updated to match.


Verification

  • go build, go vet, go test -race green on Go 1.21 (the go.mod minimum) and on Go 1.24.1.
  • golangci-lint clean, gofmt clean.
  • New tests: NXDOMAIN classification, the *url.Error → *net.OpError → *net.DNSError chain as the error actually arrives, transient-DNS-stays-retryable, IsDNSNotFound, the retry-attempt-count regression case, and the caller-request shield.

Note on the merge commit

275d98a reconciles main into develop. main takes squash merges, so its 0c887e8 is the squash of 049707e — byte-identical trees (beed00c), different SHAs, guaranteed conflict. Resolved in develop's favour after confirming git diff --cached HEAD was empty, i.e. the merge changes nothing in develop.

Oswaldo Montaño added 5 commits July 25, 2026 14:04
The banner (1200x630 SVG, dark ground with the Go cyan retry-ring mark)
tops the README and doubles as the social preview source; the PNG export
for the GitHub setting ships alongside. README fixes: the error kind
example used ErrKindCancelled which does not compile (the constant is
ErrKindCanceled), the roadmap still listed the removed per-host limiter
and pre-release status, and DecodeJSON was missing from the fluent API
examples.
… request

The Timeout middleware deep-copied the request (req.Clone) when all it needed
was to attach a new context. The copy was redundant: Do already clones the
caller's request before the chain runs, so mid-chain independence protects
nobody -- it only duplicated the struct, the URL and the header map on every
request.

Do's clone is the sole remaining shield for the caller's request, so add
TestClient_Do_ShieldsCallerRequestFromMiddleware to pin that invariant: a
middleware that sets a header must reach the transport without leaking the
header back onto the caller's request.

Measured (min of 5, linux/amd64, Go 1.24.1):

  AllMiddleware   13 -> 11 allocs, 1589 -> 1304 B/op
  WithTimeout     12 -> 10 allocs
  overhead tier   12 -> 10 allocs (comparative harness)
  e2e tier        76 -> 74 allocs

Refresh benchmarks/REPORT.md and the README benchmark tables accordingly.
Verified on Go 1.21 (go.mod minimum): build, vet and test -race all green.
Cut 0.2.0 rather than a patch: the release adds public API
(ErrKindDNSNotFound, IsDNSNotFound) and changes observable behavior.
Callers who relied on NXDOMAIN being retried, or on IsDNS matching only
the transient kind, will see a difference without touching their code.

Contents:
- fix: NXDOMAIN classifies as the non-retryable ErrKindDNSNotFound
- perf: Timeout uses WithContext, full stack 13 -> 11 allocs
main takes squash merges, so its 0c887e8 is the squash of develop's 049707e.
Git sees two unrelated commits touching the same README lines and conflicts,
even though the trees are byte-identical (both beed00c).

Resolved in favour of develop, which is a strict superset: git cherry reports
0c887e8 as already applied, and the resulting tree matches develop's HEAD
exactly. The only conflicting hunk was the roadmap Status line, v0.2.0 on
develop against the stale v0.1.0 on main.
@oswaldom-code
oswaldom-code merged commit 68e909d into main Aug 5, 2026
7 checks passed
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