Skip to content

feat(networking): retry transient transport errors behind opt-in [IDE-2419] - #690

Merged
basti-snyk merged 3 commits into
feat/IDE-1890-network-retry-opt-infrom
feat/IDE-2419-transport-error-retry
Aug 5, 2026
Merged

feat(networking): retry transient transport errors behind opt-in [IDE-2419]#690
basti-snyk merged 3 commits into
feat/IDE-1890-network-retry-opt-infrom
feat/IDE-2419-transport-error-retry

Conversation

@basti-snyk

@basti-snyk basti-snyk commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Description

Extends the opt-in from #686 (configuration.NETWORK_REQUEST_RETRIES_ENABLED) to also retry transient transport-level connection errors (e.g. ECONNRESET), not just HTTP status codes. This closes a gap documented in IDE-2050: today, every transport error is wrapped in backoff.Permanent(rtErr) and never retried, regardless of any retry configuration.

Two-axis allow-list (both must hold to retry), per IDE-2050's resolved design:

  • Error axis: only ECONNRESET (including Windows' WSAECONNRESET/10054, which Go does not map onto the Unix errno) and net.Error timeouts (including DNS timeouts) — never DNS NotFound, context cancellation/deadline, or TLS/auth failures.
  • Request axis: only idempotent/replayable requests (safe method or idempotency key) — mirroring Go's own net/http.Transport.isReplayable. A reset/timeout is ambiguous about whether the server already processed the request, so non-idempotent requests are never resent.

Gating: PREVIEW_FEATURES_ENABLED || NETWORK_REQUEST_RETRIES_ENABLED activates transport-level retry — mirroring the existing OR-condition already used for the attempt-count default in defaultMaxNetworkRequestAttempts(). Zero behavior change for anyone who doesn't set either flag.

On a transport-level retry, a warn-level log line is emitted (no separate user-facing notification, consistent with how 5xx/408/425 retries already behave silently today — only 429 has a notification).

Correctness pitfalls specifically pinned by tests:

  • context.DeadlineExceeded also satisfies net.Error.Timeout() — the deny-list (context cancellation, DNS NotFound, TLS/auth) runs before the allow-list, so a caller-abandoned request is never retried just because it looks like a timeout.
  • Windows' WSAECONNRESET (syscall.Errno(10054)) is checked explicitly alongside syscall.ECONNRESET, since errors.Is alone does not bridge them — CI runs on win/server-2022 and this would otherwise pass on Linux while silently failing to retry on Windows.

Implements IDE-2419, sub-task of IDE-1890, sibling of IDE-2412 (#686, base of this stacked PR) and IDE-2415 (deferred CLI wiring). ADR-1 amended to record this as an explicit decision-maker override of its original "real captured trace" gate on this change, based on documented customer demand rather than a satisfying trace.

Note on size: this diff is ~900 lines (82 production, ~820 test) against the repo's 700-line PR guideline. Deliberately shipped as one PR rather than split — the bulk is a genuine 3-layer TDD pyramid (acceptance against the real composition root with a real TCP server simulating RST, integration wiring tests, and a full two-axis classifier matrix including the deny-before-allow-list and Windows-errno edge cases) for a correctness-sensitive retry-safety feature. Splitting production code from the tests that pin it would leave an interim PR either under-tested or carrying dead code.

Test plan

  • Acceptance: real composition root + real TCP server forcing RST, opted-in consumer recovers from a connection reset on an idempotent request
  • Acceptance regression guards: no-opt-in behavior, non-idempotent (POST) requests never retried on transport error, existing status-code-only retry paths unaffected
  • Acceptance: preview-features-only also activates transport retry (mirrors the attempt-count OR-condition) — Test_TransportRetry_PreviewFeaturesOnly_ConnectionResetRecovers
  • Integration: real RetryMiddleware wiring, including a dedicated test that goes RED if the PREVIEW_FEATURES_ENABLED || NETWORK_REQUEST_RETRIES_ENABLED OR-condition is reverted to flag-only
  • Unit: full two-axis classifier matrix (error type × request replayability), deny-before-allow ordering, Windows WSAECONNRESET handling

Checklist

  • Tests added and all succeed (make test)
  • Regenerated mocks, etc. (make generate)
  • Linted (make lint)
  • Test your changes work for the CLI
    1. Clone / pull the latest CLI main.
    2. Run go get github.com/snyk/go-application-framework@YOUR_LATEST_GAF_COMMIT in the cliv2 directory.
      • Tip: for local testing, you can uncomment the line near the bottom of the CLI's go.mod to point to your local GAF code.
    3. Run go mod tidy in the cliv2 directory.
    4. Run the CLI tests and do any required manual testing.
    5. Open a PR in the CLI repo now with the go.mod and go.sum changes.
    • Once this PR is merged, repeat these steps, but pointing to the latest GAF commit on main and update your CLI PR.

Note

Medium Risk
Changes core HTTP retry behavior for transport errors (duplicate-request and exhaustion semantics), but it is gated behind opt-in/preview flags and covered by extensive safety tests including non-replayable POST and attempt limits.

Overview
Extends network retry middleware so opted-in apps (or preview features) can recover from transient transport failures—connection reset and network timeouts—not only retryable HTTP status codes.

Gating matches the existing resilient-retry opt-in: PREVIEW_FEATURES_ENABLED or NETWORK_REQUEST_RETRIES_ENABLED. No change for callers that set neither flag.

Retry rules (both must pass):

  • Error: ECONNRESET (including Windows WSA 10054), net.Error timeouts; never context cancel/deadline, DNS not found, TLS/auth, or generic errors. Deny-list runs before timeout allow-list so DeadlineExceeded is not retried.
  • Request: Replayable requests only (safe methods or idempotency headers, aligned with net/http replay rules); non-idempotent POSTs without a key stay single-attempt.

On transport retry, a warn log is emitted; attempt budget and Snyk-Request-Attempt-Count behave like status-code retries. New retry_transport_error helpers classify errors and replayability.

Tests add a raw TCP server that forces RST, composition-root acceptance cases, and middleware/unit matrices for the two-axis policy.

Reviewed by Cursor Bugbot for commit 6a883e7. Bugbot is set up for automated code reviews on this repo. Configure here.

…-2419]

Extend the network-retry opt-in (NETWORK_REQUEST_RETRIES_ENABLED, or
PREVIEW_FEATURES_ENABLED) to also cover transient transport-level
failures - connection resets and network timeouts - which were
previously wrapped in backoff.Permanent and never retried regardless
of retry configuration.

Adds a two-axis allow-list mirroring net/http.Transport's own
isReplayable:
- error axis: connection reset (POSIX ECONNRESET and Windows
  WSAECONNRESET, matched by numeric errno since Go does not map the
  latter onto the former) or a network timeout (net.Error.Timeout());
  DNS NotFound, TLS failures, and caller cancellation/deadline are
  denied. context.DeadlineExceeded also satisfies net.Error.Timeout(),
  so the deny-check runs before the timeout allow-check.
- request axis: safe methods (GET/HEAD/OPTIONS/TRACE, or no method
  set) are always replayable; other methods are replayable only when
  they carry an Idempotency-Key or X-Idempotency-Key header.

New file pkg/networking/middleware/retry_transport_error.go holds the
two unexported predicates. retry_middleware.go's RoundTrip now checks
them (gated behind the opt-in and the remaining attempt budget) before
falling back to the existing unconditional backoff.Permanent behavior.

Outside-in TDD: acceptance tests exercise the real composition root
(CreateAppEngineWithOptions -> GetUnauthorizedHttpClient) against a
real TCP server that simulates a connection reset; integration tests
wire the real RetryMiddleware; unit tests cover the two predicates in
isolation.
@basti-snyk
basti-snyk requested review from a team as code owners August 4, 2026 13:50
@snyk-io

snyk-io Bot commented Aug 4, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@snyk-io

snyk-io Bot commented Aug 4, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues
Secrets 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@snyk-pr-review-bot

This comment has been minimized.

Bastian Doetsch added 2 commits August 5, 2026 08:03
…IDE-2419]

Log the transport-error retry at Warn instead of Debug so operators see
it without raising verbosity, per explicit requirement. Driven by TDD:
extended Test_RetryMiddleware_TransportError_ConnResetRetriedWhenOptedIn
to capture zerolog output via a buffer and assert on
`"level":"warn"`, confirmed RED against the prior Debug() call, then
changed Debug() to Warn() in retry_middleware.go and confirmed GREEN.

Remove WHAT-only comments (restating what the following code obviously
does, or just repeating a test's name plus a plan-ID) across
retry_middleware.go, retry_middleware_test.go, retry_transport_error.go,
retry_transport_error_test.go, app_test.go, and constants.go. Genuine
WHY comments (non-obvious design reasons, ordering constraints, numeric
literals, external references) are kept.

Remove duplicate test coverage now that unit tests fully exercise the
classification functions:
- Test_RetryMiddleware_TransportError_ErrorAxis and
  Test_RetryMiddleware_TransportError_RequestAxis trimmed to two rows
  each, enough to prove the middleware is wired to
  isRetryableTransportError/isReplayableRequest without re-testing
  their full branch coverage (already covered by
  Test_isRetryableTransportError and Test_isReplayableRequest).
- Test_RetryMiddleware_TransportError_BodyReplayedOnEveryAttempt
  deleted: Test_TransportRetry_OptIn_RetriedRequestSentInFull now
  asserts every recorded body (not just the last) against
  expectedBody, subsuming it at the acceptance level.
Correction to the prior cleanup commit against this repo's stricter
Rule 8 (comments only for non-obvious WHY, no reasoning/alternatives,
no ticket IDs, no big blocks):

- Cut the connResetErr and fakeTimeoutError rationale comments in
  retry_middleware_test.go: both fixtures are self-explanatory from
  their names and bodies.
- Cut the WHAT-only resettingServerLog doc comment and the
  precedence-matrix table label in app_test.go; both restated what
  is already evident from the field names.
- Condensed the networkRequestRetryAfterSecondsKey and
  newResettingServer comments in app_test.go to their essential WHY
  clause, dropping restated context.
- Shrunk the NETWORK_REQUEST_RETRIES_ENABLED godoc in constants.go
  from a four-line block to a single trailing comment matching its
  neighbors' style; the precedence nuance with an explicit attempts
  count belongs in the ticket, not source.

No ticket-ID references (IDE-1890-*/IDE-2419-*) remain in any of the
six touched files. The errWSAEConnReset rationale, the
context.DeadlineExceeded-before-timeout ordering note, and the
SetLinger(0) RST-vs-FIN note are retained as genuine load-bearing WHY.
@snyk-pr-review-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Minor Budget Inconsistency 🟡 [minor]

In the transport error retry block, attemptLimit is initialized to maxAttempts (the global config) but defaults to *cachedMaxRetries if a previous HTTP response was received. However, cachedMaxRetries is only populated inside the rtErr == nil block. If the very first attempt results in a transport error, cachedMaxRetries will be nil and attemptLimit will be 1 (the default), potentially skipping retries even if the user opted in, because maxAttempts is initialized to defaultMaxAttemptsCount (which is 1) at the start of RoundTrip.

attemptLimit := maxAttempts
if cachedMaxRetries != nil {
	attemptLimit = *cachedMaxRetries
}
📚 Repository Context Analyzed

This review considered 27 relevant code sections from 13 files (average relevance: 0.83)

🤖 Repository instructions applied (from AGENTS.md)

@basti-snyk
basti-snyk merged commit 5064317 into feat/IDE-1890-network-retry-opt-in Aug 5, 2026
13 of 16 checks passed
@basti-snyk
basti-snyk deleted the feat/IDE-2419-transport-error-retry branch August 5, 2026 08:20

@cursor cursor 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.

Automated verification

Ran a 4-lens review (semantic analysis, adversarial review, security scan, independent code review) against the diff between d117fa3 and 6a883e7.

Approval policy: this PR modifies actual Go source/test files (retry_middleware.go, retry_transport_error.go, constants.go, and their tests) rather than only dependency manifests (go.mod/go.sum). Per this automation's policy, only pure dependency-bump PRs are auto-approved — everything else requires human review, so this review does not approve the PR.

Summary

  • Critical: none
  • Should Fix: 2 (both minor — doc-comment accuracy and log-level consistency; see inline comments)
  • Suggestion: 1 (dead/unreachable guard clause)
  • Security: no exploitable issues found (opt-in gating, replayability check mirrors Go stdlib's own isReplayable, bounded attempt budget, no sensitive data in the new log line). Corroborated by the PR's own green Snyk CI checks.
  • Build/Lint/Test: go build ./..., make lint (0 issues), and go test -race -cover on the touched packages all pass, with strong coverage (88–95%) across the new retry-classification and integration tests.

Overall this is a well-scoped, well-tested, additive change (two-axis allow-list gated behind the existing opt-in flags, no breaking changes to public API surface). The two Should-Fix items below are worth addressing before merge but are not blockers.

Open in Web View Automation 

Sent by Cursor Automation: Automatic PR verification

INPUT_DIRECTORY string = "targetDirectory" // INPUT_DIRECTORY ([]string) sets/returns the input directories that the application shall process
FLAG_EXPERIMENTAL string = "experimental" // FLAG_EXPERIMENTAL (boolean) returns if experimental features shall be enabled or not, workflows should register this value as a flag to indicate that they might change before being GAed
PREVIEW_FEATURES_ENABLED string = "internal_preview_features_enabled" // PREVIEW_FEATURES_ENABLED (boolean) indicates if preview features shall be enabled, this can be used to limit features to the preview version only
NETWORK_REQUEST_RETRIES_ENABLED string = "internal_network_request_retries_enabled" // NETWORK_REQUEST_RETRIES_ENABLED (boolean) opts in to the framework's resilient network-retry policy without requiring PREVIEW_FEATURES_ENABLED

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should Fix: This doc comment regressed compared to the pre-PR version — it dropped two facts a consumer of this exported constant needs: (1) that an explicitly configured attempts count always takes precedence over this switch, and (2) that this PR extends the flag's effect to also unlock transport-error retries in RetryMiddleware.RoundTrip (not just the default-attempts bump). Since pkg/ is this repo's public, load-bearing API surface, the godoc should stay accurate.

Suggested restore:

// NETWORK_REQUEST_RETRIES_ENABLED (boolean) opts the application in to the framework's
// resilient network-retry policy, without requiring PREVIEW_FEATURES_ENABLED. When true,
// it also raises the default middleware.ConfigurationKeyRequestAttempts and enables
// retrying transient transport-level errors (e.g. connection resets, timeouts) for
// idempotent/replayable requests. An explicitly configured attempts count always takes
// precedence over this switch.

actualAttempts < attemptLimit &&
isRetryableTransportError(rtErr) &&
isReplayableRequest(&localRequest) {
rm.logger.Warn().Err(rtErr).Msgf("Retrying request after transient transport error (attempt %d/%d)", actualAttempts, attemptLimit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should Fix: This logs at Warn on every in-progress transport-error retry, while the existing status-code retry path logs at Debug while retrying and only escalates to Warn once retries are ultimately exhausted (see filterRetryError, ~line 310). With this opt-in enabled, a request that hits one transient reset and recovers on the next attempt now emits a Warn for what is a routine, successful resilience action — noisier than an equivalent 503/429 retry, and inconsistent with the rest of this file's logging convention.

Suggested fix — log at Debug here (matching the status-code path) and reserve Warn for exhaustion:

rm.logger.Debug().Err(rtErr).Msgf("Retrying request after transient transport error (attempt %d/%d)", actualAttempts, attemptLimit)


// isReplayableRequest mirrors net/http.Transport's own isReplayable.
func isReplayableRequest(req *http.Request) bool {
if req.Body != nil && req.Body != http.NoBody && req.GetBody == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This req.GetBody == nil deny-check is unreachable from its only call site. isReplayableRequest(&localRequest) is always called after ensureGetBodyExists(req) has already run unconditionally in RoundTrip, which guarantees GetBody is non-nil whenever Body is non-nil/non-NoBody — that guarantee is carried into localRequest via the shallow copy. Not a bug (it's still correctly tested via Test_isReplayableRequest, which constructs a request that bypasses ensureGetBodyExists), but as written it implies this call site is protected against unreadable bodies when the real guarantee comes entirely from the upstream preprocessing. Consider a short comment noting this guard is for callers other than RetryMiddleware.RoundTrip, or drop it if isReplayableRequest is never meant to be called standalone.

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