Skip to content

fix: Set explicit upstream dial timeout and retry failed dials - #20

Merged
Tubt merged 2 commits into
masterfrom
fix/upstream-dial-timeout-and-retry
Aug 12, 2026
Merged

fix: Set explicit upstream dial timeout and retry failed dials#20
Tubt merged 2 commits into
masterfrom
fix/upstream-dial-timeout-and-retry

Conversation

@Tubt

@Tubt Tubt commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Record and proxy modes built their upstream HTTP client as &fasthttp.Client{} with no Dial set, so fasthttp fell back to its own DefaultDialTimeout3 seconds — and never retried a failed dial.

Three seconds is shorter than a rolling load-balancer or ingress update. While the balancer's targets are draining, SYNs go unanswered, the dial times out, and the caller receives a 502 even though the upstream is perfectly healthy.

The chain, all in existing code:

Step Location
Client created with no dial config internal/pureproxy/pureproxy.go:28, internal/record/record.go:54
fasthttp falls back to 3s fasthttp@v1.69.0/tcpdialer.go:411DefaultDialTimeout = 3 * time.Second
Error returned tcpdialer.go:376ErrDialTimeout = errors.New("dialing to the given TCP address timed out")
Wrapper adds the address ErrDialWithUpstream.Error()"error when dialing %s: %s"
GoodMock wraps it as 502 internal/pureproxy/pureproxy.go:62, internal/record/record.go:94

Observed impact

In one GoodData gdc-ui nightly e2e run (30769815868, 2026-08-02) this produced 44 × 502/503 inside a single 2-minute window — durations min 3002 ms, max 3704 ms, avg 3119 ms, i.e. the 3s dial timeout firing repeatedly:

{"error": "proxy error: error when dialing 3.229.73.213:443: dialing to the given TCP address timed out"}

The three addresses appearing in those errors were the three availability-zone nodes of the same upstream load balancer, which was mid-rollout. Because GoodMock synthesises the 502 itself, the request never reaches the upstream — so there is no server-side trace, and the failures are effectively undiagnosable from backend logs.

Change

  • PROXY_DIAL_TIMEOUT — TCP connect timeout, Go duration, default 30s (record + proxy modes)
  • PROXY_DIAL_ATTEMPTS — how many times a request is re-dialed when the connection cannot be established, default 3, 1 disables
  • The dialer is now owned by the client (a fasthttp.TCPDialer with Concurrency: 1000, matching fasthttp's own default) rather than the process-global one behind fasthttp.DialTimeout, so the DNS cache and concurrency limiter are scoped to this client

Why retrying is safe here

Retrying is scoped to errors.Is(err, fasthttp.ErrDialTimeout) and nothing else.

That error means the TCP connection was never established, so the upstream cannot have seen — let alone applied — the request. That makes a retry safe for non-idempotent methods including POST. Once any bytes are on the wire, nothing is retried, because there is no way to know whether the upstream acted on them.

errors.Is reaches through ErrDialWithUpstream, which implements Unwrap().

What happens across multiple upstream addresses

When the upstream resolves to several addresses, consecutive attempts tend to start from different ones. getTCPAddrs advances a round-robin index (idx := atomic.AddUint32(&e.addrsIdx, 1)) once per dial, and dial() abandons the walk as soon as one address returns ErrDialTimeout instead of trying the remainder:

if errors.Is(err, ErrDialTimeout) {
    return nil, err
}
idx++

This rotates over fasthttp's cached address set — names are only re-resolved after DNSCacheDuration (1 minute). An earlier revision of this description claimed each attempt re-resolves the address; that was wrong and has been corrected here and in the code comments.

Deliberately unchanged

Read and write timeouts stay unset. Upstream responses in this proxy's use case can legitimately take tens of seconds, and capping them would convert slow-but-successful calls into proxy errors.

Compatibility

The dial-timeout default changes from an implicit 3s to an explicit 30s. Anything relying on failing fast after 3s can restore the old behaviour with PROXY_DIAL_TIMEOUT=3s PROXY_DIAL_ATTEMPTS=1. Recorded/replayed mapping formats are untouched. Minor version bump, 0.11.00.12.0.

Verification

✅ go build ./...
✅ go vet ./...
✅ gofmt -l .        (clean)
✅ go test ./...     (incl. 7 new cases in internal/common/common_test.go)
✅ npx keep-a-changelog@2.8.0 CHANGELOG.md
✅ VERSION bumped 0.11.0 → 0.12.0 with a matching CHANGELOG entry

The default-case tests were also checked against a hostile environment — PROXY_DIAL_TIMEOUT=5s PROXY_DIAL_ATTEMPTS=5 go test ./internal/common/ passes.

Not covered by tests: the retry loop itself, which would need a listener that accepts then blackholes SYNs to exercise honestly. The env-var parsing and defaults are tested; the invalid-value paths call log.Fatalf and would need a subprocess harness the repo does not currently have.

Summary by CodeRabbit

  • New Features

    • Added configurable upstream dial timeouts via PROXY_DIAL_TIMEOUT, defaulting to 30 seconds.
    • Added configurable connection attempts via PROXY_DIAL_ATTEMPTS, defaulting to three attempts.
    • Dial timeouts retry within the configured limit, with address rotation and DNS caching.
  • Bug Fixes

    • Non-timeout connection errors return immediately without retries.
    • Responses reset safely between retry attempts.
  • Documentation

    • Updated README and changelog with configuration details and version 0.12.0 release information.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6e4b393c-eef2-4ec7-a3f8-1ef272ea4ccd

📥 Commits

Reviewing files that changed from the base of the PR and between d46a3ed and d8bd1ad.

📒 Files selected for processing (1)
  • VERSION
🚧 Files skipped from review as they are similar to previous changes (1)
  • VERSION

📝 Walkthrough

Walkthrough

Version 0.12.0 adds configurable upstream dial timeouts and retry attempts. A shared client applies these settings in proxy and record modes. Proxy requests retry dial-timeout failures. Documentation describes the new behavior.

Changes

Upstream dialing

Layer / File(s) Summary
Dial configuration and validation
internal/common/common.go, internal/common/common_test.go
Added ProxyDialTimeout with a 30-second default and ProxyDialAttempts with a three-attempt default. Tests cover configured and default values.
Shared client and retry flow
internal/proxy/proxy.go, internal/proxy/proxy_test.go, internal/pureproxy/pureproxy.go, internal/record/record.go
Added NewClient with configurable dial timeouts. Proxy requests retry only dial-timeout failures up to the configured attempt count. Proxy and record servers use the shared client. Tests verify attempt counts and non-timeout errors.
Release and usage documentation
README.md, VERSION, CHANGELOG.md
Updated the version to 0.12.0 and documented timeout, retry, and release changes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProxyRequest
  participant NewClient
  participant fasthttpTCPDialer
  participant Upstream
  ProxyRequest->>NewClient: Create client with configured dial timeout
  ProxyRequest->>fasthttpTCPDialer: Send upstream request
  fasthttpTCPDialer->>Upstream: Establish TCP connection
  Upstream-->>fasthttpTCPDialer: Return dial result
  fasthttpTCPDialer-->>ProxyRequest: Return response or ErrDialTimeout
  ProxyRequest->>fasthttpTCPDialer: Retry timeout failures up to configured attempts
Loading

Poem

A rabbit sets the timeout gate,
Then counts each dial attempt.
The proxy retries timeout errors,
While records share the client.
Version twelve records the change.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 clearly summarizes the main changes: explicit upstream dial timeouts and retries for failed dials.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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: 3

🤖 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/common/common_test.go`:
- Around line 24-26: Make the environment-dependent subtests deterministic by
always calling t.Setenv with tt.env, including when it is empty. Update both
PROXY_DIAL_TIMEOUT at internal/common/common_test.go lines 24-26 and
PROXY_DIAL_ATTEMPTS at lines 47-49; remove the conditional guards while
preserving each test’s existing assertions.

In `@internal/proxy/proxy.go`:
- Around line 23-28: Update NewClient in internal/proxy/proxy.go (lines 23-28)
to create a client-owned fasthttp.TCPDialer with DisableDNSResolution enabled
and invoke its DialTimeout method from the callback, preserving
fasthttp.ErrDialTimeout. In internal/common/common.go (lines 45-50), preserve
the ProxyDialAttempts contract while ensuring retries use the fresh-resolution
dial path; make no unrelated changes.

In `@README.md`:
- Line 74: Document PROXY_DIAL_ATTEMPTS as a maximum total TCP dial-attempt
limit, clarifying that the initial dial is included and a value of 3 means one
initial attempt plus two retries. Update README.md lines 74-74 and 84-84 to use
total-attempt wording, and update CHANGELOG.md line 10-10 to state that the
value controls total dial attempts rather than re-dials.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e93cb89f-5f6d-4d11-b01e-fdf9e87cfe98

📥 Commits

Reviewing files that changed from the base of the PR and between 0c56111 and 3dcabd4.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • README.md
  • VERSION
  • internal/common/common.go
  • internal/common/common_test.go
  • internal/proxy/proxy.go
  • internal/pureproxy/pureproxy.go
  • internal/record/record.go

Comment thread internal/common/common_test.go Outdated
Comment thread internal/proxy/proxy.go Outdated
Comment thread README.md Outdated
Tubt added a commit that referenced this pull request Aug 3, 2026
Addresses review feedback on #20.

Use a client-owned fasthttp.TCPDialer instead of the package-level
fasthttp.DialTimeout, which delegates to a process-global dialer. Owning
it scopes the DNS cache and the concurrency limiter to this client rather
than sharing them with anything else in the process that dials via
fasthttp. Concurrency is set to 1000 to match fasthttp's own default
dialer so that owning it changes nothing else.

Correct an inaccurate claim in the docs: attempts were described as
re-resolving the address. They do not. fasthttp caches resolved
addresses for DNSCacheDuration (1 minute by default). What actually
happens is that getTCPAddrs advances a round-robin index over the cached
set once per dial, and dial() abandons the walk as soon as one address
returns ErrDialTimeout rather than trying the remainder — so consecutive
attempts do start from consecutive addresses, but over the cached set,
not a freshly resolved one. Fixed in the ProxyDialAttempts doc comment
and in README.

Also make the default-case subtests independent of the caller's
environment. They previously skipped t.Setenv when the fixture value was
empty, so an inherited PROXY_DIAL_TIMEOUT or PROXY_DIAL_ATTEMPTS decided
the result: with PROXY_DIAL_TIMEOUT=5s the unset case failed with
"ProxyDialTimeout() = 5s, want 30s". Setting the variable unconditionally
(empty included, which is what the getters treat as unset) isolates them.
Tubt added a commit that referenced this pull request Aug 3, 2026
…ests

Addresses review feedback on #20.

The loop counts the initial dial, so PROXY_DIAL_ATTEMPTS=3 performs three
dials — one plus two retries — not four. README, CHANGELOG and the
ProxyDialAttempts doc comment all described it as a re-dial count, which
reads as one more than it is. Reworded as a total-attempt budget.

Add internal/proxy/proxy_test.go covering the contract with a counting
dialer:

- the budget is total, verified at 3 (default), 1 (retrying disabled) and
  4 (explicit)
- the count holds for GET as well as POST. fasthttp's HostClient retries
  idempotent methods internally up to DefaultMaxIdemponentCallAttempts,
  and this pins that it does not stack on top of our loop for a dial
  failure and multiply the dials
- a non-dial-timeout error is not retried and is not misclassified as a
  dial timeout, which is what keeps the retry safe for non-idempotent
  methods

This closes the gap called out in the PR description, where the retry
loop itself was untested.

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

🧹 Nitpick comments (1)
internal/proxy/proxy_test.go (1)

15-18: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise GET failures after connection establishment.

Use a net.Conn whose write or read fails after Dial succeeds. A dial error returns retry=false before connection acquisition, but write and read errors return retry=true, so GET can retry internally up to DefaultMaxIdemponentCallAttempts. Assert one dial for this non-dial failure so it cannot bypass PROXY_DIAL_ATTEMPTS.

🤖 Prompt for 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.

In `@internal/proxy/proxy_test.go` around lines 15 - 18, Extend the proxy test
around the existing varied-method retry setup to use a net.Conn that succeeds
during Dial but fails on write or read, then issue a GET and assert the dial
count remains one. Cover the post-connection failure path so retry=true does not
trigger fasthttp's internal GET retries beyond PROXY_DIAL_ATTEMPTS.
🤖 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.

Nitpick comments:
In `@internal/proxy/proxy_test.go`:
- Around line 15-18: Extend the proxy test around the existing varied-method
retry setup to use a net.Conn that succeeds during Dial but fails on write or
read, then issue a GET and assert the dial count remains one. Cover the
post-connection failure path so retry=true does not trigger fasthttp's internal
GET retries beyond PROXY_DIAL_ATTEMPTS.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4c347aa1-4480-4069-8ca8-813f08a81f64

📥 Commits

Reviewing files that changed from the base of the PR and between 3dcabd4 and 64adc29.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • README.md
  • internal/common/common.go
  • internal/common/common_test.go
  • internal/proxy/proxy.go
  • internal/proxy/proxy_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • internal/proxy/proxy.go
  • internal/common/common.go
  • internal/common/common_test.go
  • README.md
  • CHANGELOG.md

Record and proxy modes built their upstream client as &fasthttp.Client{}
with no Dial set, so fasthttp fell back to its DefaultDialTimeout of 3s
and never retried. That is shorter than a rolling load-balancer or
ingress update: while the balancer's targets drain, SYNs go unanswered,
the dial times out, and the caller gets a 502 even though the upstream is
healthy. The 502 body is fasthttp's ErrDialTimeout wrapped by
ErrDialWithUpstream, surfaced through goodmock's proxy-error handler.

Add two settings, both applying to record and proxy modes:

- PROXY_DIAL_TIMEOUT sets the TCP connect timeout, default 30s.
- PROXY_DIAL_ATTEMPTS caps the total number of dial attempts, initial
  dial included, default 3. Setting it to 1 disables retrying.

Retrying is scoped to fasthttp.ErrDialTimeout and nothing else. That
error means the TCP connection was never established, so the upstream
cannot have seen or applied the request, which makes the retry safe for
non-idempotent methods too. Once bytes are on the wire nothing is
retried, because there is no way to know whether the upstream acted on
them. errors.Is reaches the sentinel through ErrDialWithUpstream, which
implements Unwrap.

The dialer is owned by the client rather than using the package-level
fasthttp.DialTimeout, which delegates to a process-global TCPDialer.
Owning it scopes the DNS cache and the concurrency limiter to this client
instead of sharing them with anything else in the process that dials via
fasthttp. Concurrency is set to 1000 to match fasthttp's own default so
that taking ownership changes nothing else.

Read and write timeouts stay unset. Upstream responses here can
legitimately take tens of seconds, and capping them would convert
slow-but-successful calls into proxy errors.

Note on multiple upstream addresses: attempts tend to land on different
ones because getTCPAddrs advances a round-robin index once per dial and
dial() abandons the walk as soon as an address times out rather than
trying the rest. This rotates over fasthttp's cached set; names are only
re-resolved once DNSCacheDuration (1 minute) has elapsed.

Tests cover the configuration getters and the retry contract with a
counting dialer: the attempt budget is total, verified at 3, 1 and 4; the
count holds for GET as well as POST, which guards against fasthttp's own
idempotent-retry loop stacking on top and multiplying the dials; and a
non-dial-timeout error is neither retried nor misclassified as a dial
timeout. The default-case getter tests set their variables
unconditionally so an inherited environment cannot decide the result.
@Tubt
Tubt force-pushed the fix/upstream-dial-timeout-and-retry branch from 64adc29 to cf5ef87 Compare August 3, 2026 09:02

@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 `@README.md`:
- Line 86: Update the README retry behavior description to state that retries
occur only when the dial error matches fasthttp.ErrDialTimeout; clarify that
other connection-establishment failures, including connection refused, are not
retried, while preserving the existing safety explanation for non-idempotent
requests.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b1e25a6b-248b-46a7-9f55-dac86014e060

📥 Commits

Reviewing files that changed from the base of the PR and between 64adc29 and cf5ef87.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • README.md
  • VERSION
  • internal/common/common.go
  • internal/common/common_test.go
  • internal/proxy/proxy.go
  • internal/proxy/proxy_test.go
  • internal/pureproxy/pureproxy.go
  • internal/record/record.go
🚧 Files skipped from review as they are similar to previous changes (8)
  • internal/record/record.go
  • internal/pureproxy/pureproxy.go
  • VERSION
  • internal/proxy/proxy.go
  • internal/common/common_test.go
  • internal/proxy/proxy_test.go
  • internal/common/common.go
  • CHANGELOG.md

Comment thread README.md Outdated
Comment thread VERSION Outdated
Comment thread CHANGELOG.md

@martinnaj martinnaj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Solid, well-scoped change overall — gating the retry strictly on ErrDialTimeout is the right call, and the test pinning GET vs POST dial counts (so fasthttp's internal idempotent retries can't stack on top of the loop) is a genuinely good catch. A few notes, the first one is the only one I'd insist on before merge:

  1. Env config is read on every request instead of once — see the inline comment on ProxyRequest. This also means an invalid PROXY_DIAL_ATTEMPTS kills the process on the first proxied request instead of at startup.
  2. The PR description's "Not covered by tests: the retry loop itself" is now inaccurateproxy_test.go covers the loop directly via the injected Dial (attempt budgets, the disable case, the non-dial-error early exit). What's genuinely uncovered is only the real-network accept-then-blackhole behaviour. Worth updating so reviewers know where to look hard.
  3. CHANGELOG rewrites history — blank-line removal and Added/Changed/Fixed reordering in already-released entries (0.2.0, 0.4.0, 0.5.0) is churn unrelated to this change and pollutes blame on a file where blame is the point. I'd revert everything except the new 0.12.0 entry and its link.
  4. Nit: NewClient living in internal/proxy while being consumed by record and pureproxy is slightly odd layering — defensible since ProxyRequest lives there too, not blocking.
  5. Nit: no backoff between attempts is actually correct for the LB-drain scenario (immediate retry rotates to the next address), but a sentence in the code comment saying it's deliberate would stop the next reader from "fixing" it.

Comment thread internal/proxy/proxy.go Outdated
// the request, which makes a retry safe for every method including POST.
// Any other failure is returned as-is: once bytes are on the wire we have no
// way to know whether the upstream acted on them.
attempts := common.ProxyDialAttempts()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

common.ProxyDialAttempts() re-reads and re-parses the env var on every proxied request. Two problems:

  1. Validation happens at first request, not startup. ProxyDialTimeout() is called in NewClient(), so a bad value kills the process at boot — good. But an invalid PROXY_DIAL_ATTEMPTS passes startup, the pod goes Ready, and then log.Fatalf takes the whole proxy down when the first request arrives.
  2. Inconsistent snapshot semantics — the timeout is fixed at client construction while attempts are re-read per request.

Suggest resolving both once at construction (capture attempts in NewClient's scope and pass it into ProxyRequest, or validate eagerly at startup). I suspect the per-request read was chosen to make t.Setenv testing easy — but the tests can just as well construct the client after setting the env.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch on both — fixed in d46a3ed, taking the first of your two suggestions.

NewClient now resolves both settings and returns a proxy.Client that carries the budget with the client:

type Client struct {
	HTTP     *fasthttp.Client
	Attempts int
}

func NewClient() *Client {
	timeout := common.ProxyDialTimeout()
	attempts := common.ProxyDialAttempts()
	...
}

ProxyRequest takes *proxy.Client and reads client.Attempts, so an invalid PROXY_DIAL_ATTEMPTS is now fatal at boot alongside PROXY_DIAL_TIMEOUT, and the two settings are on one snapshot instead of one being frozen in the dialer closure while the other is re-read.

You were right about the motivation, too — the per-request read was there for t.Setenv. The retry-loop tests now set the budget on the Client directly, which is a better test anyway (they were asserting the retry loop, not the env parsing). TestNewClientAttempts covers the env wiring they no longer touch, and common_test.go still covers the parsing and validation.

RecordServer / ProxyServer each keep a single *proxy.Client field, so there is no second field to drift.

Comment thread README.md
Tubt added a commit that referenced this pull request Aug 10, 2026
Address review feedback on #20.

common.ProxyDialAttempts() was re-read on every proxied request. It calls
log.Fatalf on an invalid value, so a bad PROXY_DIAL_ATTEMPTS passed startup,
the pod went Ready, and the proxy then died on its first request. It also left
the two dial settings on different snapshots: the timeout was captured in
NewClient's dialer closure while the attempts were re-read per request.

Both are now resolved once in NewClient, which returns a proxy.Client bundling
the fasthttp client with its attempt budget. The retry-loop tests set the budget
on the Client directly instead of through t.Setenv; TestNewClientAttempts covers
the environment wiring they no longer exercise.

Docs: state the retry is scoped to a dial timeout (connection refused is not
retried), and spell out the compound attempts x timeout worst case with its two
consequences for callers.

Revert the unrelated CHANGELOG reformatting - blank lines after old version
headings and section reordering within 0.5.0 and 0.2.0.
@Tubt
Tubt enabled auto-merge August 11, 2026 04:29
Address review feedback on #20.

common.ProxyDialAttempts() was re-read on every proxied request. It calls
log.Fatalf on an invalid value, so a bad PROXY_DIAL_ATTEMPTS passed startup,
the pod went Ready, and the proxy then died on its first request. It also left
the two dial settings on different snapshots: the timeout was captured in
NewClient's dialer closure while the attempts were re-read per request.

Both are now resolved once in NewClient, which returns a proxy.Client bundling
the fasthttp client with its attempt budget. The retry-loop tests set the budget
on the Client directly instead of through t.Setenv; TestNewClientAttempts covers
the environment wiring they no longer exercise.

Docs: state the retry is scoped to a dial timeout (connection refused is not
retried), and spell out the compound attempts x timeout worst case with its two
consequences for callers.

Revert the unrelated CHANGELOG reformatting - blank lines after old version
headings and section reordering within 0.5.0 and 0.2.0.
@Tubt
Tubt force-pushed the fix/upstream-dial-timeout-and-retry branch from d46a3ed to d8bd1ad Compare August 12, 2026 02:22
@Tubt
Tubt merged commit 6fc54a0 into master Aug 12, 2026
7 checks passed
@Tubt
Tubt deleted the fix/upstream-dial-timeout-and-retry branch August 12, 2026 08:11
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.

2 participants