An outgoing request can fail before you know whether the receiving service
processed it. A retry gives transient failures another chance, but repeating a
write without the receiver’s deduplication contract can duplicate its effects.
Arkoren’s client package adds retry policy to a standard http.RoundTripper;
you still use Go’s http.Client for requests, redirects, cookies, and responses.
If you are adding this to an existing project, follow existing application installation. No separate client module is required.
Create Clients
For a client used to read reports, import net/http, time, and
arkoren.dev/core/http/client. Set a whole-operation timeout when constructing
the client:
outgoing := &http.Client{
Transport: client.Transport(http.DefaultTransport),
Timeout: 8 * time.Second,
}
The default permits three total attempts, including the first. It retries
transport errors and responses with status 408, 429, 502, 503, or 504;
500 is not retryable by default. Eligibility also depends on the request’s
method and body, as described in Permit Retries.
In particular, the default considers PUT and DELETE eligible, not only reads.
Do not share this policy with writes unless the receiving API honors their
idempotent semantics.
Reuse clients across requests and goroutines. Configure the client and base
transport before sharing them. Construction opens no connections. Passing nil
as the base selects http.DefaultTransport; it does not disable retries.
The default backoff uses full jitter, starting with a 100-millisecond upper bound, doubling up to 10 seconds. The client’s timeout covers all attempts, retry waits, and reading the final response body—not eight seconds per attempt.
Read Responses
With your caller’s ctx and report URL endpoint, create a request carrying
that context:
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
Handle a construction error before passing the request to Do. Then inspect
the final response status before consuming its data. This fragment also uses
fmt and io inside a function returning an error:
res, err := outgoing.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("read report: unexpected status %s", res.Status)
}
data, err := io.ReadAll(res.Body)
Retries finish before Do returns. A final 503 is an HTTP response with a nil
Go error, so checking err alone does not establish success. Close the final
body on every successful Do, including non-2xx responses and body-read errors.
Reading to EOF before closing permits connection reuse when the native
transport can reuse it.
ReadAll is suitable only for a bounded response. Stream large bodies or impose
an application-specific size limit on untrusted data. A read error after Do
returns does not trigger another request; restarting a partial download is your
application’s decision.
The retry wrapper closes the native body once and then releases the attempt
context. Repeated or concurrent Close calls return the first close result;
EOF and I/O errors alone do not release that ownership. This does not make
concurrent reads safe. Capture close errors too if your operation needs to
report them.
Permit Retries
A request is eligible for another attempt only if both conditions hold:
- Its method is
GET,HEAD,OPTIONS,TRACE,PUT, orDELETE, or it has a nonemptyIdempotency-Keyheader. - It has no body, or
Request.GetBodycan recreate that body.
These are transport rules, not proof that an endpoint is safe to repeat. For a write API without a verified replay contract, use an explicitly single-attempt client:
writes := &http.Client{
Transport: client.Transport(http.DefaultTransport, func(transport *client.Builder) {
transport.Attempts(1)
}),
Timeout: 8 * time.Second,
}
This disables Arkoren’s retries while retaining instrumentation. Inspect any SDK, proxy, application, and native transport replay behavior separately; this setting is not an exactly-once delivery guarantee.
Only opt a POST or PATCH into retries if the destination documents how it
deduplicates an operation using Idempotency-Key. The header itself does not
create that behavior. For a report-creation API with that contract, use an
application-persisted operationID for the same logical operation on every
retry. With strings imported and the operation’s JSON in payload:
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Idempotency-Key", operationID)
Send this request through outgoing.Do and handle its final response as above.
Do not generate a new key for an uncertain retry, and follow the receiver’s key
retention and payload-matching rules. A body from strings.Reader,
bytes.Reader, or bytes.Buffer gets GetBody from Go’s request constructor;
an arbitrary streaming reader does not. Without GetBody, a nonempty body is
sent once even if its method or key would otherwise permit retries.
Each attempt clones the request. Before a resend, the transport drains up to 4 KiB of the discarded response, closes it, waits, then recreates the request body. Drain or close errors do not prevent a retry. A body-recreation failure returns an error wrapping its cause without sending that attempt. You own only the final response body returned to you.
Respect Retry-After
For retryable 429 and 503 responses, a valid Retry-After replaces the
configured backoff. A wait greater than 10 seconds, or greater than the remaining
request deadline, stops retries and returns that response with its body still
yours to close. The ten-second cap is fixed; increasing the backoff maximum
does not increase it.
The header accepts unsigned whole decimal seconds or an HTTP date parsed by
http.ParseTime, after trimming whitespace. A past date means no wait.
Signed or fractional values such as +2, -1, and 1.5 fall back to backoff;
an oversized unsigned decimal instead exceeds the cap and stops retries.
Other statuses ignore this header, even if a custom policy permits retrying them.
There is no separate exhaustion error. The final outcome remains a response or transport error:
| Final Outcome | Caller Receives |
|---|---|
All attempts return 503 |
The final 503, with nil error from Do. |
| All attempts fail at the transport | An error from Do, normally wrapped in *url.Error. |
| A response is not retryable | That response immediately. |
Retry-After exceeds the allowed wait |
That response without another attempt. |
| The context ends during a retry wait | A context error, with no next attempt. |
Bound Requests
Use a request context for an operation-specific budget instead of mutating a
shared client. With context imported:
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(bounded, http.MethodGet, endpoint, nil)
Keep this context alive until you have read and closed the response body.
Canceling immediately after Do can interrupt subsequent reads. The earliest
applicable deadline wins; the request context reaches every attempt and bounds
retry waits.
If one attempt must not consume the whole operation budget, set an additional per-attempt limit:
outgoing := &http.Client{
Transport: client.Transport(http.DefaultTransport, func(transport *client.Builder) {
transport.AttemptTimeout(2 * time.Second)
}),
Timeout: 8 * time.Second,
}
An attempt timeout includes reading its response body. Backoff is outside that individual budget, so retain an overall deadline. Zero or negative attempt timeouts set no per-attempt limit, which is the default. A timeout before response headers can be retried while the overall context remains active; a timeout reading the final body cannot.
Custom transports must implement context-aware I/O. The retry wrapper cannot force an arbitrary blocked read, write, or close to return.
Upgraded Streams
For 101 Switching Protocols, the body retains io.Writer and
io.ReadWriteCloser only if the native body supports writing. Native byte counts
and errors pass through unchanged. Your protocol code owns closure, and the
attempt context stays active until Close.
If you need the writable interface through http.Client, leave its Timeout
at zero: Go’s client timeout adds a read/close-only wrapper. Use a request
context or AttemptTimeout for the applicable budget, subject to the native
stream’s cancellation behavior. Arkoren adds no write deadline or forced
interruption to upgraded I/O.
Tune Policy
Choose retry settings from the destination’s documented behavior. For an API
that explicitly treats 500 as transient, replace the outcome predicate while
retaining the other default outcomes:
func retryReports(res *http.Response, err error) bool {
if err != nil {
return true
}
switch res.StatusCode {
case 408, 429, 500, 502, 503, 504:
return true
default:
return false
}
}
Attach this function and, if needed, a different jittered backoff during construction:
transport := client.Transport(http.DefaultTransport, func(transport *client.Builder) {
transport.RetryIf(retryReports)
transport.Backoff(client.Backoff(200*time.Millisecond, 2, 2*time.Second))
})
RetryIf replaces rather than extends the predicate. Exactly one of its
response and error is nonnil. It cannot override method safety, body
replayability, the attempt limit, or cancellation. Nil restores the default
predicate; nil Backoff restores the default delay function.
Attempts(0) selects three total attempts; negative values send once. A custom
backoff receives the attempt about to run: 2 for the first retry. Nonpositive
delays mean no wait; avoid repeated immediate retries against a struggling
service. While the caller context is active, RetryIf runs after every attempt,
including the last. Backoff runs only when another attempt is eligible, before
Retry-After selection—even if that header ultimately stops the retry.
Own Transports
If you already own a transport with proxy, TLS, or pool settings, pass it as the base rather than replacing it with nil:
outgoing := &http.Client{
Transport: client.Transport(base),
Timeout: 8 * time.Second,
}
Configure base before sharing it. Retain it for pool management:
Transport does not forward CloseIdleConnections to its base. When retiring
an owned native pool, call base.CloseIdleConnections() yourself; do not close
a shared pool on behalf of unrelated users.
If you want an owned copy of Go’s default transport, use NewTransport. With
crypto/tls imported, keep normal server verification enabled:
transport, err := client.NewTransport(&tls.Config{
MinVersion: tls.VersionTLS12,
})
if err != nil {
return err
}
outgoing := &http.Client{Transport: transport, Timeout: 8 * time.Second}
Construction clones the current nonnil *http.Transport default and preserves
its proxy, pooling, and HTTP/2 settings. Another default implementation returns
an error before configuration callbacks run; wrap that implementation with
Transport instead. Call outgoing.CloseIdleConnections() when retiring this
owned pool; active requests are not interrupted.
NewTransport(nil) preserves the default’s TLS settings. A nonnil configuration
replaces them with a shallow clone. Prepare nested pools, certificates, and
callback state before sharing; neither the supplied configuration nor the
default is modified. For private roots and client identities, follow
TLS Certificates. Certificate errors can
be retried as transport errors, but retrying cannot fix invalid trust, expiry,
or names.
An SDK can use this *http.Client too, but choose one layer to own retries.
Three SDK attempts around three transport attempts can send nine requests.
Trace Attempts
Every attempt that reaches the underlying transport gets a client span when
telemetry is active. Retries carry http.request.resend_count. Ordinary spans
include body reading and end on EOF, a read error, or closure; no-body and
upgrade spans end at headers. Rewind failures before network I/O create no
network-attempt span.
By default instrumentation follows telemetry.Default(). With an existing
application, choose its shared handle
explicitly:
transport := client.Transport(http.DefaultTransport, func(transport *client.Builder) {
transport.Telemetry(app.Telemetry())
})
Do not also wrap the base with telemetry.Transport; the retry transport
already instruments attempts and a second wrapper can duplicate spans.
Trace injection defaults to telemetry.PropagateInternal: internal IP literals,
single-label hosts, and recognized internal suffixes qualify; resolving a
public-looking name to a private address does not. This is a destination-name
policy, not authentication. It does not sanitize trace headers you manually
put on the request. Keep sensitive headers out of caller-owned requests and
apply an explicit outgoing policy
when a destination requires a different boundary.
For a client that must not inject trace context, import
arkoren.dev/core/service/telemetry and set the predicate:
transport := client.Transport(http.DefaultTransport, func(transport *client.Builder) {
transport.Propagate(telemetry.PropagateNone)
})
Spans still record the request. Propagate replaces injection policy, not retry
policy. Expanding it to a partner can disclose trace identifiers and baggage;
allow only destinations you trust to receive that data. Incoming remote-parent
acceptance is a separate incoming trust
decision.
Observe Failures
Retries produce debug records on slog.Default() unless you supply a logger
with transport.Logger(logger). Records contain the method, hostname, attempt,
delay, and status or error type—not paths, queries, bodies, or error text.
Enable debug level on that logger to see them. These are planned retry records,
not a log of every call.
To receive terminal outcome events, set transport.Events(app.Events()) in the
construction callback and subscribe to client.RequestFailed on that bus.
Its payload includes Method, hostname-only Host, Attempts, StatusCode,
and Err. A transport failure has zero status; an HTTP response has nil error.
Keep listeners short because dispatch is synchronous, and
own the subscription for the
observed client’s lifetime.
The event reports terminal transport errors and responses that still match
RetryIf when attempts or allowed Retry-After waits run out. It does not
report every non-2xx response: default-policy 400 and 500 responses do not
emit it. No event is emitted if the caller context is already done at the
post-attempt check, during cancellation of a retry wait, or for body-read errors
after Do. Events are disabled without a bus and are not a durable retry queue.
Unlike retry logs, event Err retains the original error and can expose
sensitive diagnostics. Redact it before export; see
Telemetry Privacy.
For backoff edge cases, construction rules, and every option, see the
client API reference.