Skip to content

rhttp v0.2.0 — NXDOMAIN no longer retried

Latest

Choose a tag to compare

@oswaldom-code oswaldom-code released this 05 Aug 15:57
· 6 commits to main since this release
68e909d

Second release of rhttp. One correctness fix in the error taxonomy and one allocation removed from the hot path. Still zero dependencies, still Go 1.21+.

Permanent DNS failures are no longer retried

ErrKindDNS was retryable unconditionally, so DefaultIsRetryable retried NXDOMAIN — a failure that is permanent by definition. A misspelled hostname or a DNS entry removed during a migration burned the entire attempt budget plus the full backoff schedule on an outcome that was already decided at the first lookup. The resolver had been reporting net.DNSError.IsNotFound == true the whole time; nothing read it.

Against this-host-does-not-exist.invalid with MaxAttempts: 4 and a 100 ms constant backoff:

v0.1.0 v0.2.0
Classify(err).Kind dns dns_not_found
Kind.IsRetryable() true false
transport attempts 4 1
wall clock 304 ms 0 s

The fix lives in the classification, not in the retry predicate. classifyError now consults IsNotFound and returns a distinct ErrKindDNSNotFound that is excluded from IsRetryable — which means all three public paths agree at once: DefaultIsRetryable, the package-level IsRetryable(err), and Classify(err).Kind.IsRetryable(). Special-casing the error inside the retry middleware would have left the other two still answering true.

ErrKindDNS was deliberately not flipped wholesale. DNS timeouts already classify as ErrKindTimeout, so what remains under ErrKindDNS is a genuine mix — SERVFAIL and friends stay retryable, as they should. Same distinction the AWS SDK for Go v2 and gRPC-Go make.

Lower allocation floor

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

Benchmark v0.1.0 v0.2.0
Full middleware stack 13 allocs / 1589 B 11 allocs / 1304 B
Timeout only 12 allocs 10 allocs
As a wrapper vs competitors 12 allocs 10 allocs
End-to-end (loopback) 76 allocs 74 allocs

Measured 2026-08-05, linux/amd64, i7-1255U, min of 5 runs. Full tables and methodology: benchmarks/REPORT.md.

Upgrading

Minor rather than patch, because behavior changes without you touching a line:

  • NXDOMAIN is no longer retried. If you were relying on those retries, they are gone. This is the point of the release.
  • IsDNS(err) now matches both DNS kinds, transient and permanent. It stays true for NXDOMAIN — an NXDOMAIN is still a DNS failure, and making it false would have broken existing callers silently. Use the new IsDNSNotFound(err) when you specifically want the permanent case.
  • ErrKindDNSNotFound is appended last in the ErrorKind block, so the numeric values shipped in 0.1.0 do not shift.

No API was removed. If you already narrowed retries with a custom IsRetryable, nothing changes for you.

Quality

  • build, vet and test -race green on Go 1.21 (the go.mod minimum), 1.22 and 1.23 in CI.
  • New tests cover NXDOMAIN classification, the *url.Error → *net.OpError → *net.DNSError chain as the error actually arrives from the transport, transient-DNS-stays-retryable, the retry attempt-count regression, and the invariant that Do shields the caller's request from middleware mutation.

Install

go get github.com/oswaldom-code/rhttp@v0.2.0

Full changelog: CHANGELOG.md