Skip to content

Make uhttp transport timeouts configurable and add connection retry-with-reconnect#1003

Open
johnallers wants to merge 1 commit into
mainfrom
jallers/uhttp-configurable-timeouts-and-retry
Open

Make uhttp transport timeouts configurable and add connection retry-with-reconnect#1003
johnallers wants to merge 1 commit into
mainfrom
jallers/uhttp-configurable-timeouts-and-retry

Conversation

@johnallers

Copy link
Copy Markdown
Contributor

Summary

The uhttp transport hardcodes its connection timeouts — idle-conn (30s), response-header (60s), and the HTTP/2 PING keepalive (15s+15s = 30s). Those defaults were deliberately tuned for chatty API connectors behind an egress proxy, but two gaps fall out of them:

  1. A single transient connection blip can abort a whole sync. When a proxy silently drops a pooled/tunneled connection, it surfaces as http2: client connection lost or connection reset, which wrapTransientNetworkError maps to codes.Unavailable. Nothing in uhttp retries it, so the error propagates up and the sync workflow discards its in-progress c1z and restarts. On a long, per-entity (N+1) walk through a flaky proxy, a run may rarely get an uninterrupted window to completion.
  2. No way to tolerate slow synchronous endpoints. An endpoint that blocks before sending response headers (e.g. a report-generation call) hits the fixed 60s ResponseHeaderTimeout — which, because the transport uses http2.ConfigureTransports, applies over HTTP/2 too — with no knob to raise it.

What changed

Configurable timeouts (pkg/uhttp): new options WithIdleConnTimeout, WithResponseHeaderTimeout, WithHTTP2KeepAlive, and WithMaxConnectionRetries. The tuning fields become nil-able pointers: a nil option preserves the existing default (via the new default* constants), so an explicit 0 can still mean "disabled/unlimited" per net/http semantics. Timeout defaults are unchanged.

Retry-with-reconnect (pkg/uhttp): a new retryRoundTripper sits below the error-wrapping layer and transparently replays a request on reconnectable transport errors (new IsReconnectableError helper — http2 client connection lost, ECONNRESET, EPIPE, net.ErrClosed, unexpected/EOF; timeouts/DeadlineExceeded are deliberately excluded). When the HTTP/2 health check tears down a dead ClientConn it is evicted from the pool, so the retried RoundTrip dials fresh — retry here also reconnects. Only idempotent requests (safe methods, or a request carrying an Idempotency-Key) with a rewindable body are retried; jittered exponential backoff is bounded by the request context.

Config plumbing (pkg/field, pkg/cli): a new http-response-header-timeout-seconds field (default 60, 0 disables) is injected into the run context and consumed by the transport. An explicit WithResponseHeaderTimeout option takes precedence over the config value, mirroring the existing http-timeout-seconds behavior.

⚠️ One behavior change

WithMaxConnectionRetries defaults to 3 — previously there was no retry. This is the intended fix, and it's scoped tightly (idempotent + rewindable + reconnectable-only, context-bounded). To opt out, pass WithMaxConnectionRetries(0). Happy to flip the default to opt-in (0) if reviewers prefer.

Note

The retry sits at the transport layer, below BaseHttpClient.Do's rate limiter, so a retried attempt does not take a fresh rate-limit token. For rare connection-death retries this is negligible; called out for visibility.

Testing

go test ./pkg/uhttp/... ./pkg/field/... and golangci-lint pass. New tests cover error classification, retry-until-success, stop-at-max, non-reconnectable/non-idempotent/non-rewindable guards, body rewind on retry, context cancellation, and option-vs-config precedence.

🤖 Generated with Claude Code

The uhttp transport hardcoded its connection timeouts (idle-conn,
response-header, and the HTTP/2 PING keepalive), tuned for chatty API
connectors behind a proxy. Two problems followed:

- A transient connection-level failure -- a proxy silently dropping a
  pooled/tunneled connection, surfacing as "http2: client connection
  lost" or "connection reset" -- was wrapped as a fatal
  codes.Unavailable and returned with no retry, which could abort an
  entire sync on a single blip.
- Endpoints that block before sending response headers (e.g. a
  synchronous report-generation call) hit the fixed 60s
  ResponseHeaderTimeout with no way to raise it.

Changes:
- Expose the previously-hardcoded transport timeouts as options:
  WithIdleConnTimeout, WithResponseHeaderTimeout, WithHTTP2KeepAlive,
  and WithMaxConnectionRetries. Timeout defaults are unchanged; a nil
  option leaves the default in place so an explicit 0 can still mean
  "disabled/unlimited".
- Add retryRoundTripper: transparently reconnect-and-replay on
  reconnectable transport errors (new IsReconnectableError helper).
  Only idempotent requests (safe methods or an Idempotency-Key) with a
  rewindable body are retried; slow-peer timeouts are excluded. Uses
  jittered exponential backoff bounded by the request context.
  Behavior change: retries default to 3 (previously none); set
  WithMaxConnectionRetries(0) to opt out.
- Plumb the response-header timeout from CLI/config via a new
  http-response-header-timeout-seconds field; an explicit option takes
  precedence over the config value.

Adds unit tests for error classification, retry/backoff behavior,
idempotency and body-rewind guards, context cancellation, and
option/config precedence.
@johnallers
johnallers requested a review from a team July 9, 2026 21:00
Comment thread pkg/uhttp/transport.go
// Transparently reconnect-and-retry transient connection-level failures so a
// proxy silently dropping a pooled/tunneled connection doesn't surface as a
// fatal error to the caller (e.g. aborting a whole sync).
if maxRetries := intOr(t.maxConnRetries, defaultMaxConnectionRetries); maxRetries > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: defaultMaxConnectionRetries = 3 changes default behavior for every downstream connector (previously zero retries). Two things worth confirming per SDK-compat criteria: (1) unlike the response-header timeout, there's no CLI/config field for max retries, so connectors can only disable it via a code-level WithMaxConnectionRetries(0) — consider a config field for parity; (2) pkg/sdk/version.go is still v0.18.1 and wasn't bumped to signal this default-behavior change. The scoping (idempotent + rewindable + reconnectable-only, context-bounded) looks sound. (confidence: medium)

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Make uhttp transport timeouts configurable and add connection retry-with-reconnect

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base dda1a0dbe73c.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. The change makes uhttp transport timeouts configurable via nil-able pointer fields (nil = preserve default, explicit 0 = disabled per net/http semantics) and adds a transport-level retryRoundTripper that transparently replays idempotent, rewindable requests on reconnectable transport errors. The nil-pointer default pattern, context-value resolution, body-rewind logic, and error classification are correct and well-tested. I verified the PR's claim that ResponseHeaderTimeout applies over HTTP/2 — the vendored x/net/http2 transport does honor t1.ResponseHeaderTimeout (transport.go:1270), so that behavior is accurate. No new security or correctness issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/transport.go:188 — retry defaults to 3 (previously none), a default-behavior change with no CLI/config knob to disable and no version.go bump to signal it.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/uhttp/transport.go`:
- Around line 188 (and the `defaultMaxConnectionRetries = 3` constant near line 28):
  Connection retry now defaults to 3 attempts where previously there were none.
  This changes default behavior for all downstream connectors that import the SDK.
  Consider two adjustments: (1) add a CLI/config field (mirroring
  `http-response-header-timeout-seconds` in pkg/field/defaults.go and pkg/cli/commands.go)
  so operators can disable/tune retries without code changes, since currently the only
  way to opt out is a code-level WithMaxConnectionRetries(0); and (2) bump
  pkg/sdk/version.go (currently v0.18.1) to signal the default-behavior change, per the
  repo's SDK-compatibility criteria. Alternatively, flip the default to 0 (opt-in) to
  preserve prior behavior. No code correctness issue — this is a compatibility/signaling call.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@kans

kans commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Sol 5.6 says:

Cancellation during retry backoff returns the prior network error instead of context.Canceled/DeadlineExceeded, misclassifying canceled requests as retryable Unavailable errors. Fix both cancellation paths in pkg/uhttp/transport.go.

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.

3 participants