Skip to content

fix(providers): retry provably pre-send transport failures (#447)#750

Open
Vasanthdev2004 wants to merge 7 commits into
mainfrom
fix/retry-presend-transport
Open

fix(providers): retry provably pre-send transport failures (#447)#750
Vasanthdev2004 wants to merge 7 commits into
mainfrom
fix/retry-presend-transport

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #447.

The ask, and the stance it touches

A flaky link that fails the streaming completion POST at connect time (TLS handshake timeout, connection refused, DNS failure) stops the turn and forces the user to type continue. The reporter asked for auto-retry of transport failures, with the right safety instinct: retry only failures that provably never reached the model.

The shared retry layer deliberately does NOT retry transport errors, and the reasoning is sound and documented: a completion POST is non-idempotent, so if the request may have reached the server, a replay could duplicate billable work.

This PR is opening that decision for your sign-off, not treating it as settled. It changes a stance you staked out on purpose, so it should be your call.

Why a narrow carve-out is consistent with that stance

The "may have reached the server" concern does not apply to a provably pre-send failure, where no request bytes ever left this host. There a replay cannot duplicate anything, which is exactly the logic that already makes 429 and 503 safe to retry (the server told us it did not accept the request).

So SendWithRetry now retries, with the existing backoff and attempt budget, only that narrow class:

  • DNS resolution failure (net.DNSError, matched by type through any wrapping)
  • a refused or unreachable dial (connection refused, network is unreachable, no route to host)
  • a TLS handshake timeout (the handshake completes before any HTTP request bytes are written)

Everything ambiguous or post-send stays unretried, and those exclusions are checked first so a message carrying both an excluded and an included marker can never be misread as pre-send: connection reset, broken pipe, EOF, a bare i/o timeout (which also covers post-send read timeouts), context deadline.

This deliberately narrows the issue's ask (it listed connection reset, which stays unretried) down to the provably-safe subset. The existing reset-stays-unretried test stays green.

Scope and placement

Every provider send path routes the completion POST through SendWithAuthRetry then SendWithRetry, so this one change covers them uniformly, and it fires before the in-band stream error that stops the turn is ever emitted. The 429/503 path, the auth-retry path, and streaming are untouched.

A note on the classification

Pre-send detection uses errors.As for the DNS type, errors.Is for EOF, and narrow string markers for the dial/TLS cases. String markers are the pragmatic choice here (they are stable and portable, unlike platform-specific syscall errnos). The one theoretical way a post-send error could carry an inclusion marker is a url.Error whose URL contains the phrase, which cannot happen: every marker contains a space, and the HTTP client rejects a space in the host and percent-encodes it in the path.

Testing

go build, go vet, gofmt clean; full internal/providers/... suite passes. New tests cover retry-on-pre-send (refused dial, network unreachable, TLS handshake timeout, DNS), no-retry-on-ambiguous (i/o timeout, broken pipe, EOF), and the predicate classification table including the exclusion-wins and host-named-eof cases.

Summary by CodeRabbit

  • Bug Fixes
    • Improved retry behavior to reattempt only provably pre-send failures (DNS/dial/TLS handshake) and to stop immediately on context cancellation.
    • Prevented retries for ambiguous post-send/completion failures to avoid duplicating non-idempotent work.
    • Added a dedicated, bounded sub-second pre-send backoff schedule with platform-aware detection for dial refusal/unreachability.
  • Tests
    • Expanded transport-error test coverage, including retry limits, classification precedence/exclusions, and validation of the pre-send backoff timing schedule.

A flaky link that failed the streaming completion POST at connect time
stopped the turn and forced a manual continue, because the shared retry
layer never retries transport errors: a completion POST is
non-idempotent, so a replay could duplicate billable work.

That rationale does not apply to a PROVABLY pre-send failure, where no
request bytes ever left this host: a replay cannot duplicate anything,
exactly like a 429/503 where the server told us it did not accept the
request. SendWithRetry now retries, with the existing backoff, only that
narrow class: DNS resolution failure, a refused or unreachable dial, and
a TLS handshake timeout. Every ambiguous or post-send failure stays
unretried and is checked first so a mixed message can never be misread:
connection reset, broken pipe, EOF, a bare i/o timeout, context
deadline. All three providers route their completion POST through this
layer, so one change covers them uniformly.

This deliberately narrows the issue's ask (which listed connection
reset) to the provably-safe subset, and keeps the reset-stays-unretried
test green. Tests cover the retry-on-pre-send, no-retry-on-ambiguous,
and predicate-classification paths.

NOTE FOR REVIEW: this refines a deliberately conservative, documented
no-retry stance. Opening it for the owner's sign-off on the stance, not
as a settled call.
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 531c9fa530be
Changed files (5): internal/providers/providerio/retry.go, internal/providers/providerio/retry_dialerrno.go, internal/providers/providerio/retry_dialerrno_plan9.go, internal/providers/providerio/retry_dialerrno_windows.go, internal/providers/providerio/retry_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

SendWithRetry now retries narrowly classified pre-send DNS, dial, and TLS handshake failures with a separate bounded backoff. Ambiguous or post-send failures remain non-retryable, with platform-specific errno handling and expanded tests.

Changes

Transport retry safety

Layer / File(s) Summary
Pre-send transport error classification
internal/providers/providerio/retry.go, internal/providers/providerio/retry_dialerrno*.go
Documents and classifies retryable pre-send failures, excludes ambiguous completion-like errors, and defines platform-specific dial errno values.
Bounded retry execution and backoff
internal/providers/providerio/retry.go
SendWithRetry handles context cancellation, applies bounded pre-send retries, and uses a separate sub-second exponential schedule.
Transport retry validation
internal/providers/providerio/retry_test.go
Tests verify replay limits, classification precedence, exclusions, platform-shaped errors, backoff shrinking, and schedule clamping.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SendWithRetry
  participant HTTPClient
  participant preSendBackoff
  SendWithRetry->>HTTPClient: client.Do(request)
  HTTPClient-->>SendWithRetry: pre-send transport error
  SendWithRetry->>preSendBackoff: apply bounded pre-send delay
  preSendBackoff-->>SendWithRetry: retry delay
  SendWithRetry->>HTTPClient: client.Do(request) again
Loading

Possibly related PRs

  • Gitlawb/zero#132: Introduces the shared providerio.SendWithRetry retry logic extended by this change.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers safe pre-send DNS, dial-refused/unreachable, and TLS timeout retries, but it omits connection-reset retry and jitter requested by the issue. Add provably pre-send connection-reset handling and jittered exponential backoff, or document why those cases are intentionally excluded.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main change to retry pre-send transport failures.
Out of Scope Changes check ✅ Passed The added platform errno helpers, tests, and docs all support the retry behavior and no unrelated changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/retry-presend-transport

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/providers/providerio/retry.go`:
- Around line 159-175: Update isPreSendTransportError to recognize
Windows-specific connection-refused and network-unreachable dial error wording
or errno text while preserving the existing exclusion precedence and POSIX
matches. Add a Windows-formatted error case to the tests in retry_test.go that
verifies the error is classified as pre-send and retried.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b065d57b-09dd-4fd0-99da-fb5f33795841

📥 Commits

Reviewing files that changed from the base of the PR and between ce4a996 and b76856c.

📒 Files selected for processing (2)
  • internal/providers/providerio/retry.go
  • internal/providers/providerio/retry_test.go

Comment thread internal/providers/providerio/retry.go
CodeRabbit flagged that the pre-send predicate matched only POSIX dial
wording, so a refused or unreachable dial on Windows (whose text differs
entirely: 'No connection could be made because the target machine
actively refused it') would not be retried.

Match the syscall errno via errors.Is instead: Go maps ECONNREFUSED /
ENETUNREACH / EHOSTUNREACH / ECONNRESET to the host's real values,
including the WSA codes on Windows, so classification is identical on
every platform. Errno matching is also authoritative where the string
markers were only heuristic, closing the theoretical substring gap. The
string markers stay as a fallback for errors already flattened to text.
ECONNRESET is added to the post-send exclusions by identity so a Windows
reset is excluded too.

Tests build the production error shape (net.OpError -> os.SyscallError ->
errno) so the Windows path is exercised portably on every platform, plus
an end-to-end SendWithRetry case for an errno-wrapped refused dial. Cross
-compiled and vetted for linux and darwin.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Good catch, fixed in 4c54a8b. Windows dial wording is completely different ("No connection could be made because the target machine actively refused it"), so the POSIX string markers would have missed it.

Rather than add Windows strings, the predicate now classifies by syscall errno via errors.Is: ECONNREFUSED / ENETUNREACH / EHOSTUNREACH for the pre-send inclusions and ECONNRESET for the post-send exclusion. Go maps those constants to the host's real values (the WSA codes on Windows), so the classification is identical on every platform, and it's authoritative where the strings were only heuristic. The string markers stay as a fallback for errors that arrive already flattened to text with no errno in the chain.

Tests build the real production error shape (net.OpError wrapping os.SyscallError wrapping the errno), which is the portable way to exercise the Windows path: the same errno assertion holds on Linux/macOS/Windows even though .Error() reads differently on each. Added errno cases to the predicate table (refused, network-unreachable, host-unreachable, and reset-is-post-send) plus an end-to-end SendWithRetry case for an errno-wrapped refused dial. Cross-compiled and vetted for linux and darwin so the platform-specific constants are confirmed to build everywhere.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE. The safety-critical property is airtight: isPreSendTransportError evaluates the post-send/ambiguous exclusions (EOF, ECONNRESET, broken pipe, i/o timeout, DeadlineExceeded) BEFORE any inclusion, so a genuinely post-send failure can never be classified pre-send and never replays a billable POST (verified: exactly 1 call on ambiguous errors). Request body is rebuilt via bytes.NewReader per attempt; context cancellation is checked first; ECONNREFUSED is connect-only; retries are bounded; no bad interaction with the auth-retry path.

Findings (none blocking):

  • [Minor] A connect/dial i/o timeout is NOT retried — Go surfaces it as *net.OpError{Op:"dial"} whose text contains i/o timeout, caught by the exclusion at retry.go:169. That misses part of #447's own motivating case (intermittent connectivity → connect timeout). Safe fix: before the string exclusion, admit *net.OpError when Op=="dial"/"connect" && Timeout() — unambiguously pre-send, without loosening the post-send read-timeout exclusion.
  • [Nit] Backoff has no jitter (issue mentioned it) — a non-issue for a single-client CLI; optional.
  • [Nit] ENETUNREACH/EHOSTUNREACH errno admits (retry.go:176) aren't scoped to Op=="dial". Residual risk is very low and mitigated (established-connection failures surface as read reset/EOF/timeout, excluded first), but scoping to dial/connect would make the no-bytes-sent guarantee structural rather than argued.

Merge is kevin's call per the program gate.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Request changes

The safety reasoning here is genuinely careful, but the central claim doesn't hold: isPreSendTransportError matches the pre-send errnos anywhere in the error chain regardless of which socket operation raised them, so a network-unreachable error on a read/write after the request was fully sent is classified as "provably pre-send" and the non-idempotent completion POST is replayed.

Build/vet/tests are clean — see the bottom of this review.


[Major] Pre-send errno classification is not scoped to the dial, so a post-send failure replays a billable POST

internal/providers/providerio/retry.go:177-181

if errors.Is(err, syscall.ECONNREFUSED) ||
	errors.Is(err, syscall.ENETUNREACH) ||
	errors.Is(err, syscall.EHOSTUNREACH) {
	return true
}

errors.Is matches the errno wherever it appears in the chain — it says nothing about which operation produced it. These errnos are not dial-only: the kernel reports them on an established connection too, when a route goes away or an ICMP unreachable arrives.

Concrete scenario: a user is mid-generation against api.anthropic.com, the request has been fully written, the model is producing a billable completion, and Wi-Fi drops or a VPN toggles. Linux/macOS surface that on the pending read as *url.Error -> *net.OpError{Op:"read"} -> *os.SyscallError -> EHOSTUNREACH. Verified in this worktree:

  • &net.OpError{Op: "read", Err: os.NewSyscallError("read", syscall.EHOSTUNREACH)}isPreSendTransportError returns true
  • same for Op: "write" with ENETUNREACH
  • same via the string fallback at retry.go:185 for already-flattened text like write tcp: write: network is unreachable

From there SendWithRetry (retry.go:98) replays the POST up to 5 more times — all providers pass maxAttempts=0defaultMaxRetryAttempts=6 (anthropic/provider.go:196, openai/provider.go:196, gemini/provider.go:151, openai/codex_responses.go:354) — producing up to 6 duplicate billable completions for one user turn.

Worth flagging that the existing table case gives false assurance:

{"errno reset is post-send", wrapDialErrno("read", syscall.ECONNRESET), false},  // retry_test.go:133

It passes because ECONNRESET is in the exclusion list at retry.go:165, not because the operation is inspected. No case exercises a non-dial op carrying a pre-send-classified errno.

Fix: gate on the dial phase before the errno checks — unwrap to *net.OpError and require opErr.Op == "dial" (Go sets this for connect failures) around retry.go:177-181, and apply the same gate to the "connection refused" / "network is unreachable" / "no route to host" string fallbacks at retry.go:183-186. Then add table cases for wrapDialErrno("write", syscall.ENETUNREACH) and wrapDialErrno("read", syscall.EHOSTUNREACH) expecting false.


[Major] Permanent failures now hang the agent ~60s silently, on a 429-tuned backoff

internal/providers/providerio/retry.go:98, schedule at retry.go:44-48

Deterministic, never-going-to-succeed failures go through the full retry loop on a schedule explicitly tuned for rate-limit windows:

  • NXDOMAIN — net.DNSError returns true unconditionally at retry.go:160, before IsNotFound is ever consulted
  • A refused dial to a local endpoint

Verified in this worktree with maxAttempts=0: the transport is invoked exactly 6 times, and backoffWait for attempts 1..5 sums to 2+4+8+16+30 = 60s (schedule pinned by TestBackoffWaitSchedule, retry_test.go:218).

Scenario: user points the openai provider at http://127.0.0.1:11434 for a local Ollama/LM-Studio daemon that isn't running, or mistypes a base URL. Every attempt fails with ECONNREFUSED / no such host. SendWithRetry emits no notifier callback, and the agent-layer reconnectNoticeFor never fires for these providers (StreamCompletion returns nil synchronously and delivers the error as a StreamEventError), so the TUI just sits for a full minute per turn before reporting something that was known on the first millisecond. Previously this surfaced instantly.

Fix: two parts.

  1. Don't retry net.DNSError when IsNotFound is true — NXDOMAIN is authoritative and deterministic. Only IsTimeout / IsTemporary lookups are worth replaying. (retry.go:159-162)
  2. Give the pre-send transport path its own bound and schedule instead of reusing the rate-limit one — cap around 2-3 attempts starting from a sub-second base. The mid-stream reconnect layer already uses 500ms (internal/agent/reconnect.go:34) and is a reasonable model.

[Minor] SendWithRetry's own doc comment now states the opposite of what it does

internal/providers/providerio/retry.go:53-54

The package note at retry.go:24-36 was correctly updated for the new behavior, but the function doc immediately below it wasn't:

Other 5xx and transport/network errors are returned immediately, never replayed (see the package note).

That contradicts retry.go:98. This is the doc that shows on hover/godoc at every call site in anthropic, openai, gemini and codex_responses — a maintainer reads it, concludes no transport error is ever replayed, and reasons about idempotency on exactly the assumption this PR invalidates. In a codebase where these comments are the primary record of the retry-safety contract, that's a live hazard rather than a cosmetic nit.

Fix: update retry.go:50-55 to mention the pre-send exception and point at isPreSendTransportError, matching the package note.


What's well done

Genuinely above average for this class of change, and worth saying:

  • Ambiguous/post-send errors are checked before the inclusion set (retry.go:163-173), so an error matching both is never treated as pre-send. That ordering is the right call and the "exclusion wins over inclusion" test case covers it directly.
  • EOF and ECONNRESET matched by identity via errors.Is rather than by wording (retry.go:165) — a hostname containing "eof" can't fool it.
  • ctx.Err() checked before any retry decision (retry.go:87), so cancellation never becomes a retry.
  • Deliberately leaving http2 GOAWAY/stream errors, net/http: timeout awaiting response headers, and context deadline exceeded unmatched — all correct omissions.
  • Typed net.DNSError/errno matching preferred over strings, with strings only as a fallback for already-flattened errors: the right hierarchy, and the Windows WSA* reasoning in the comment is sound.
  • TestSendWithRetryDoesNotReplayAmbiguousTransportErrors (retry_test.go:99) is a real guard — it asserts exactly one call, so a regression that started replaying resets or broken pipes would fail it.
  • shrinkBackoff correctly scoped with t.Cleanup.

The dial-scoping fix should be small; the structure it slots into is already right.


Build & tests

All clean on the PR head (go1.26.5, darwin/arm64), no failures of any kind:

  • gofmt -l ./internal/providers/providerio/ — clean
  • go build ./... — exit 0
  • go vet ./internal/providers/... — exit 0, no diagnostics
  • go test -count=1 ./internal/providers/providerio/... — ok 0.854s, all subtests pass
  • go test -count=1 -race ./internal/providers/... — exit 0 across all 5 packages, no race reports

No merge-base comparison was needed since nothing failed. The sandbox-related pre-existing failures in internal/agent and internal/cli are out of scope — this PR touches neither package.

Scope note: the stated merge base fbf8598 is not actually this branch's merge base (git merge-base fbf8598 HEAD = ce4a996). Diffing fbf8598..HEAD shows a misleading 44-file / -2225-line stat because it renders main's later commits as deletions. The real PR content vs ce4a996 is 2 files, +207/-13 — retry.go and retry_test.go — which matches the supplied diff.

Merge is kevin's call per the program gate.

…on permanent errors

Addresses gnanam's two [Major] review findings on this PR.

[Major] Pre-send errno classification was not scoped to the dial, so a post-send
read/write carrying ECONNREFUSED/ENETUNREACH/EHOSTUNREACH (a route dropping
mid-generation) was misclassified "provably pre-send" and could replay a
non-idempotent completion POST up to 6 times. errors.Is matches an errno
anywhere in the chain regardless of which operation raised it, so
isPreSendTransportError now requires an Op=="dial" *net.OpError before admitting
those errnos or their POSIX wording. That makes "no request bytes were sent"
structural: a post-send failure can no longer replay a POST. The dial errno
match still covers Windows (Go maps the WSA* codes), so the platform coverage
this PR added is preserved.

[Major] Permanent failures hung the agent ~60s on the 429-tuned schedule.
NXDOMAIN (net.DNSError with IsNotFound) is now treated as permanent and not
retried, and the pre-send path gets its own short bound and sub-second schedule
(preSendMaxAttempts=3, 500ms/1s) instead of reusing the 2/4/8/16/30s rate-limit
backoff, so a mistyped host or a dead local daemon fails in about 1.5s.

[Minor] Fixed the stale SendWithRetry doc that still claimed transport errors are
never replayed.

Tests: post-send read/write errnos and NXDOMAIN now assert exactly 1 call (no
replay); dial errnos plus the string fallback assert the pre-send cap; added the
pre-send backoff schedule. Race detector and all provider packages green.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both [Major] findings fixed in 8b4d5e5, thanks gnanam. You were right that the safety argument had a hole.

[Major] dial-scoping. You nailed it: errors.Is matches an errno anywhere in the chain regardless of which socket op raised it, so a post-send read/write carrying EHOSTUNREACH/ENETUNREACH was classified pre-send and could replay the POST. isPreSendTransportError now requires an Op=="dial" *net.OpError (via a small dialOpError helper) before admitting those errnos or their POSIX wording, so "no request bytes were sent" is structural instead of argued. Your exact repro (&net.OpError{Op:"read", ...EHOSTUNREACH}) now returns false, and there are explicit table cases for wrapDialErrno("read", EHOSTUNREACH) and wrapDialErrno("write", ENETUNREACH) expecting no replay.

[Major] fast-fail. NXDOMAIN (net.DNSError with IsNotFound) is now treated as permanent and not retried, and the pre-send path has its own bound + schedule (preSendMaxAttempts=3, 500ms/1s, modeled on reconnect.go) instead of the 2/4/8/16/30s rate-limit backoff. A dead local daemon or mistyped host now fails in about 1.5s. Added TestPreSendBackoffWaitSchedule to pin it.

[Minor] doc. Fixed the SendWithRetry comment that still claimed transport errors are never replayed.

One deliberate call worth flagging: the dial-scoping means the plain-string test fixtures (which carry no *net.OpError) no longer classify as pre-send, so I updated them to the real wrapDialErrno shape. A refused error that arrives already flattened past its OpError is now NOT retried, which is the conservative direction: we can't prove it was a dial, so we don't replay a POST on it. In production the client.Do error is fully structured, so real dial failures still retry.

@coderabbitai your Windows point is covered by the errno path: a Windows dial refusal arrives as Op=="dial" wrapping syscall.Errno(WSAECONNREFUSED), which errors.Is(err, syscall.ECONNREFUSED) matches, so it retries structurally without needing the Windows wording. The POSIX string markers stay only as a fallback for a dial error flattened past its errno.

I left the earlier [Minor] (admitting a dial i/o timeout) out of this change to keep it focused on the two blockers; happy to add it as a follow-up if you'd like. Race detector and all five provider packages are green locally.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Your plan includes PR reviews subject to rate limits. Reviews are available now.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 20, 2026
…on Windows too

CodeRabbit caught a real Windows bug, which I verified with a real dial on
Windows: errors.Is(err, syscall.ECONNREFUSED) returns FALSE for a refused dial
there, because a real Windows dial carries the raw WSAECONNREFUSED (10061) while
Go's syscall.ECONNREFUSED is a distinct value the net package never produces. The
POSIX string fallback ("connection refused") does not match the Windows wording
("No connection could be made because the target machine actively refused it")
either. So the pre-send dial classification silently never fired on Windows, the
exact platform this PR set out to fix.

Move the dial errnos into a platform-specific list (dialPreSendErrnos): POSIX
everywhere, plus the WSA* codes on Windows. The existing wrapDialErrno fixtures
wrap the Go syscall constants, which is why they passed on both platforms and
missed this. Adds TestIsPreSendTransportErrorRealRefusedDial, which does a REAL
refused dial and asserts pre-send classification, so the platform's true error
shape is exercised on every OS.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Good catch @coderabbitai, and verified on Windows rather than assumed: a real refused dial there gives errors.Is(err, syscall.ECONNREFUSED) == false, because Windows raises the raw WSAECONNREFUSED (10061) while Go's syscall.ECONNREFUSED is a distinct value the net package never produces (and the POSIX string fallback doesn't match the Windows wording either). So the pre-send dial path silently never fired on Windows, the exact platform this PR set out to fix.

Fixed in 3af605d: the dial errnos are now a platform-specific list (dialPreSendErrnos, POSIX everywhere plus the WSA codes on Windows), and TestIsPreSendTransportErrorRealRefusedDial does a REAL refused dial so the platform's true error shape is exercised, not just an errno-constant fixture (which is why the old tests passed on both platforms and missed this).

@gnanam1990 heads-up: your earlier CHANGES_REQUESTED was on the pre-fix commit 4c54a8b6. Your two [Major] findings (dial-scoping + fast-fail) landed in 8b4d5e5, and this adds the Windows errno fix on top. Would appreciate a re-look.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- current retry-related files ---'
fd -a 'retry.*\.go$|.*errno.*\.go$' internal/providers/providerio 2>/dev/null || true

printf '%s\n' '--- structure ---'
ast-grep outline internal/providers/providerio/retry.go --items all 2>/dev/null || true
ast-grep outline internal/providers/providerio/retry_test.go --items all 2>/dev/null || true

printf '%s\n' '--- relevant implementation and tests ---'
rg -n -C 12 \
  'dialPreSendErrnos|isPreSendTransportError|dialOpError|TestIsPreSendTransportErrorRealRefusedDial|ECONNREFUSED|WSAECONNREFUSED|net\.Dial|DialContext' \
  internal/providers/providerio

printf '%s\n' '--- build-constraint declarations ---'
rg -n -C 8 '^(//go:build|// \+build)|dialPreSendErrnos|syscall\.' internal/providers/providerio -g '*.go'

printf '%s\n' '--- latest relevant changes ---'
git diff --find-renames 3af605d^ 3af605d -- internal/providers/providerio || true

Length of output: 56989


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- module metadata ---'
sed -n '1,180p' go.mod

printf '%s\n' '--- CI and release platform references ---'
rg -n -C 4 \
  'GOOS|GOARCH|windows|darwin|linux|plan9|wasip1|js/wasm|cross-compil' \
  .github README.md go.mod .goreleaser.yml 2>/dev/null || true

printf '%s\n' '--- loopback ephemeral-port tests and their behavior ---'
rg -n -C 8 \
  'net\.Listen\("tcp", "(127\.0\.0\.1|localhost):0"\)|Addr\(\)\.String\(\).*Close|expected a refused dial|port accepted a connection' \
  -g '*.go' .

Length of output: 17555


@Vasanthdev2004 Re-reviewed: the Windows fix is correct. The build-tagged errno lists include the raw Winsock errors, while Op == "dial" scoping and the exclusion-first logic remain intact—so this does not reopen the post-send replay risk. The real refused-dial regression test is the right coverage for the platform-specific error shape.

One minor follow-up: the comment above wrapDialErrno in internal/providers/providerio/retry_test.go (Lines 17–22) still says that Windows syscall.Exxx constants carry the WSA values and that the fixture exercises that path. That is now known to be false; the new real-dial test is what covers raw Winsock errno behavior. Please update that comment to avoid misleading future maintenance.


✏️ Learnings added
Learnt from: Vasanthdev2004
URL: https://github.com/Gitlawb/zero/pull/750

Timestamp: 2026-07-20T08:12:53.853Z
Learning: In `internal/providers/providerio`, real Windows TCP dial failures expose raw Winsock errno values such as `windows.WSAECONNREFUSED`, which do not match `syscall.ECONNREFUSED`. Pre-send dial retry classification therefore uses a Windows-specific errno list and must retain `*net.OpError{Op: "dial"}` scoping to prevent replaying post-send failures.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/providers/providerio/retry_dialerrno.go`:
- Around line 1-14: Update the build constraint for the retry dial errno
implementation to exclude Plan 9 as well as Windows, while preserving the
existing POSIX errno definitions in dialPreSendErrnos.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 66103c7b-04fd-4760-8ffa-0d92440c8ac8

📥 Commits

Reviewing files that changed from the base of the PR and between 8b4d5e5 and 3af605d.

📒 Files selected for processing (4)
  • internal/providers/providerio/retry.go
  • internal/providers/providerio/retry_dialerrno.go
  • internal/providers/providerio/retry_dialerrno_windows.go
  • internal/providers/providerio/retry_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/providers/providerio/retry.go
  • internal/providers/providerio/retry_test.go

Comment thread internal/providers/providerio/retry_dialerrno.go Outdated
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Addressed in 7467a3b.

Plan 9 build tag (Major): applied your suggested //go:build !windows && !plan9 on retry_dialerrno.go. Worth noting the package already references syscall.ECONNRESET in retry.go (the post-send exclusion), which is likewise undefined on Plan 9, so the package was never intended to compile there. Zero targets linux/darwin/windows (the CI Smoke matrix), so I scoped the guard to prevent the specific undefined-symbol breakage you flagged rather than add a Plan 9 variant that would imply support that doesn't exist. linux/darwin/windows all cross-compile clean.

retry.go Windows wording (Minor): this is already handled by the previous commit (3af605d). isPreSendTransportError no longer relies on POSIX wording for Windows: dialPreSendErrnos now carries the WSAECONNREFUSED/WSAENETUNREACH/WSAEHOSTUNREACH codes on Windows, and TestIsPreSendTransportErrorRealRefusedDial does a real refused dial so the Windows error shape is actually exercised. That comment describes the exact bug that commit fixed.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 20, 2026
gnanam1990
gnanam1990 previously approved these changes Jul 21, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE. Re-reviewed at 7467a3bc. My earlier CHANGES_REQUESTED is fully addressed — I re-ran my exact attack against the new code rather than reading the diff.

The concern I raised, and how it's fixed

I flagged that isPreSendTransportError matched ENETUNREACH/EHOSTUNREACH/ECONNREFUSED anywhere in the error chain regardless of which socket op raised them, so a post-send read/write failure (route drop / ICMP-unreachable mid-generation) was misclassified as "provably pre-send" and would replay the non-idempotent completion POST — double billing / double tool execution, the exact harm the PR exists to prevent.

The fix is the right shape: the errno match is now gated on dialOpError(err) != nil (retry.go:220), which requires an Op=="dial" *net.OpError (retry.go:248-254). That makes "no request bytes left this host" structural — a post-send read/write carrying the same errno can't match. The string-marker fallback (connection refused / network is unreachable / no route to host) is correctly inside the same dial gate, and TLS-handshake-timeout stays outside it (genuinely pre-send by wording, not a dial OpError).

Verified with the precise scenario:

dial ENETUNREACH   -> preSend=true    (safe retry preserved)
dial EHOSTUNREACH  -> preSend=true
dial ECONNREFUSED  -> preSend=true
read ENETUNREACH   -> preSend=false   (was the bug — now NOT retried)
write EHOSTUNREACH -> preSend=false   (no billable POST replay)
read ECONNRESET    -> preSend=false

Nice additions

  • Regression coverage for exactly this: retry_test.go:102-103"host unreachable on read is post-send" and "net unreachable on write is post-send" — with a comment stating scoping to Op=="dial" must keep these from replaying the POST. That pins the fix so it can't silently regress.
  • The Windows errno split is correct and non-obvious. retry_dialerrno_windows.go adds the raw Winsock codes (WSAECONNREFUSED = 10061, etc.) because errors.Is against the POSIX syscall.ECONNREFUSED returns false on Windows — the net package never produces the POSIX value there. Without that list a refused dial would silently never retry on Windows. Good catch, and the comment explains why.
  • DNS handling is sound: NXDOMAIN (IsNotFound) is not retried (deterministic — retrying just stalls the agent through the whole backoff), while a timeout/SERVFAIL is.

Verification

gofmt and go vet ./internal/providers/providerio/ clean; the full providerio suite passes. CodeRabbit has also approved this head.

Solid iteration — the dial-scoping is the correct structural fix, not a string-match patch, and it ships with a regression test for the exact post-send case. Merge is kevin's call per the program gate.

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Request changes: this regresses Plan 9 compilation. retry_dialerrno.go excludes plan9, but retry.go still references syscall.ECONNRESET and dialPreSendErrnos, for which there is no Plan 9 definition; retry_test also directly uses the unavailable syscall types/constants. passes at this PR's merge base but fails on this head. Please provide a compatible Plan 9 fallback or gate the new code and tests consistently.

@anandh8x

Copy link
Copy Markdown
Collaborator

Verification detail for my change request: running GOOS=plan9 GOARCH=amd64 go test -c ./internal/providers/providerio succeeds at this PR's merge base and fails on this head.

retry.go referenced syscall.ECONNRESET and dialPreSendErrnos directly, but the
errno definitions live in build-tagged files that exclude Plan 9 (which has no
BSD-style errno constants), so the package stopped compiling for GOOS=plan9 on
this branch even though it builds on the merge base.

Move the two syscall touch-points behind per-platform helpers: retry.go now
calls isConnResetErrno and ranges dialPreSendErrnos, both provided by
retry_dialerrno.go, retry_dialerrno_windows.go, and a new
retry_dialerrno_plan9.go. The Plan 9 variant supplies an empty errno list and a
false isConnResetErrno, so retry.go falls back to its existing string-marker
heuristics there. Behavior on linux/darwin/windows is unchanged (isConnResetErrno
matches the same syscall.ECONNRESET as before). retry_test.go is tagged !plan9
because it constructs real syscall.Errno dial shapes.

Verified: GOOS=plan9 build and vet now pass; windows and linux build, vet, gofmt,
and the full providerio test suite stay green.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Good catch, and you were right: I checked, and main builds clean for GOOS=plan9 while this branch did not, so it was a real regression I introduced (my earlier take that the package never built on Plan 9 was wrong).

Fixed by moving the two syscall touch-points out of retry.go behind per-platform helpers. retry.go no longer references syscall or dialPreSendErrnos directly; it calls isConnResetErrno and ranges dialPreSendErrnos, both supplied by retry_dialerrno.go / retry_dialerrno_windows.go and a new retry_dialerrno_plan9.go (empty errno list + isConnResetErrno returning false, so Plan 9 falls back to the existing string-marker heuristics). retry_test.go is tagged !plan9 since it builds real syscall.Errno dial shapes.

Verified GOOS=plan9 build and vet now pass, and windows/linux build, vet, gofmt, and the full providerio suite stay green with behavior unchanged on the real targets. Re-requested your review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/providers/providerio/retry_dialerrno_plan9.go`:
- Around line 5-10: Update the package comment in retry_dialerrno_plan9.go to
accurately describe retry.go’s string-marker fallback: mention only
connection-refused, network-unreachable, and no-route-to-host dial markers, and
explicitly state that connection-reset errors remain non-retryable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 04d4997d-0ef8-4483-ba44-82e7d0b12ed8

📥 Commits

Reviewing files that changed from the base of the PR and between 7467a3b and 5430fc4.

📒 Files selected for processing (5)
  • internal/providers/providerio/retry.go
  • internal/providers/providerio/retry_dialerrno.go
  • internal/providers/providerio/retry_dialerrno_plan9.go
  • internal/providers/providerio/retry_dialerrno_windows.go
  • internal/providers/providerio/retry_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/providers/providerio/retry_dialerrno_windows.go
  • internal/providers/providerio/retry.go
  • internal/providers/providerio/retry_test.go

Comment thread internal/providers/providerio/retry_dialerrno_plan9.go Outdated
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai approve

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 21, 2026
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Comments resolved and changes approved.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Do not classify a redirect-hop failure as pre-send
    internal/providers/providerio/retry.go:123
    client.Do can follow a redirect after it has already transmitted the original non-idempotent POST. If DNS, dialing, or the TLS handshake then fails while reaching the redirect target, the resulting error satisfies isPreSendTransportError and this loop replays the original request. A 307/308 provider redirect followed by a transient target-host failure can therefore run and bill the first completion, then submit it again. Disable redirects for this request path, or preserve enough redirect-hop state to retry only a failure on the initial request; add a redirect-then-transport-failure regression test.

  • [P2] Keep the pre-send retry budget separate from status retries
    internal/providers/providerio/retry.go:123
    The pre-send condition uses the loop-wide attempt counter, which already includes 429/503 retries. After two safe status retries, the first refused-dial/DNS/TLS failure arrives as attempt 3 and is returned without any of the advertised pre-send retries; after one it gets only one retry. This contradicts the independent short pre-send budget and makes mixed rate-limit/network blips fail prematurely. Track pre-send failures (and their backoff ordinal) separately and cover a status-to-transport sequence.

  • [P3] Correct the platform fallback/fixture documentation
    internal/providers/providerio/retry_dialerrno_plan9.go:5
    internal/providers/providerio/retry_test.go:19
    The Plan 9 comment says reset classification is unavailable and lists reset as a fallback inclusion, although retry.go still excludes the platform-independent "connection reset" marker. Separately, the test fixture says Windows syscall.E* constants exercise the real WSA dial path, while the new Windows implementation correctly says real dials carry distinct windows.WSA* errors; only the real-dial test covers that branch. Correct both descriptions so future maintenance does not weaken the cross-platform retry safety coverage.

Addresses jatmn's review on #750.

[P1] client.Do could follow a 307/308 after already transmitting the original
POST, and a dial failure to the redirect target then satisfied
isPreSendTransportError and replayed the request, re-billing a completion the
first host already received. SendWithRetry now disables redirect following
(CheckRedirect returns http.ErrUseLastResponse on a cloned client), so any
dial/DNS/TLS error from Do can only come from the single initial request and
the pre-send classification is sound. A 3xx surfaces as the response for the
caller's existing non-2xx handling. Regression test asserts the POST is sent
once and the 307 is not followed.

[P2] The pre-send retry used the loop-wide attempt counter shared with 429/503
retries, so a couple of status retries exhausted the pre-send budget and the
first dial/DNS/TLS blip after them got no retry. Track pre-send failures and
their backoff ordinal in a separate counter. Regression test covers a
status-then-transport-failure sequence.

[P3] Correct the Plan 9 and wrapDialErrno comments: the Plan 9 string-marker
fallback keeps the pre-send inclusions and the separate reset exclusion, and
the errno fixtures do not reproduce a real Windows dial (that carries distinct
WSA errnos), which the real-dial test covers.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

All three fixed in the latest push, thanks jatmn, genuinely good catches.

[P1] You were right that a redirect hop breaks the pre-send guarantee. SendWithRetry now disables redirect following (CheckRedirect returns http.ErrUseLastResponse on a cloned client), so any dial/DNS/TLS error from Do can only come from the single initial request, and a 3xx surfaces as the response for the existing non-2xx handling. Added a regression test asserting the POST is sent once and the 307 is not followed.

[P2] The pre-send retries now use their own counter and backoff ordinal, independent of the loop-wide attempt counter, so 429/503 retries no longer eat the pre-send budget. Added a status-then-transport-failure test that would have caught it.

[P3] Corrected both comments (the Plan 9 string-marker fallback and the wrapDialErrno fixture note about the real Windows WSA path).

Verified: gofmt, vet, and build clean on linux/windows/plan9, and the full providerio suite passes. Re-requested your review.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Do not let pre-send retries consume the later status-retry budget
    internal/providers/providerio/retry.go:115
    preSendAttempts gives a dial/DNS/TLS failure its own limit, but each retry still advances the loop-wide attempt used by the 429/503 branch at line 158. For example, with maxAttempts=3, two safely retried refused dials followed by a 429 return that first 429 immediately; with the default, those failures also shorten the status retry window and start its backoff at a later rung. This is the inverse of the mixed sequence covered by the new test and contradicts the stated independent budgets. Track status attempts/backoff separately and add a pre-send-then-status regression case.

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.

Auto-retry transient network errors (TLS timeout, conn reset) instead of requiring manual "continue"

4 participants