fix: WithTimeout mutates a caller-supplied *http.Client — confirmed data race - #46
karlwaldman wants to merge 1 commit into
Conversation
WithTimeout wrote straight through to Client.httpClient.Timeout. When the
caller had supplied their own client via WithHTTPClient, that pointer was
theirs, so the option reconfigured an object the SDK does not own.
Two consequences, both confirmed by test before the fix:
- Cross-client contamination. Two SDK clients built over one shared
*http.Client, a WithTimeout(1ns) on the first, and the second client's
timeout measured 1ns.
- A data race. net/http reads Client.Timeout inside Do, so constructing an
SDK client with WithTimeout while a request is in flight on the same
*http.Client is a read/write race, reported by -race at client.go:118
against net/http/client.go:608.
NewClient now resolves the HTTP client once, after the options have run, into
a struct the SDK owns. A supplied client is shallow-copied: Transport, Jar and
CheckRedirect carry over and stay shared (that is what the caller supplied it
for), while Timeout — the only field the SDK sets — lives on a struct nobody
else holds.
Two contract clarifications fall out and are covered by tests:
- An explicit WithTimeout wins over a supplied client's own Timeout in
either option order. Previously whichever option ran last won.
- WithTimeout(0) still means no timeout; timeoutSet is tracked separately
from timeout so the zero value is not read as "unset".
A supplied client with no explicit WithTimeout keeps its own Timeout; the SDK
does not impose DefaultTimeout on it.
Closes #38
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Closing as superseded by #44, which fixed #38 first and merged at 19:05:05 UTC. No replacement PR. This PR's implementation duplicates #44. It resolves the HTTP client after the options run, applies an explicit timeout to a shallow copy, and keeps Before closing, I checked whether any of this PR's tests pin behaviour that main's
Main already covers both assertions, and its tests catch both failure modes on the old code. This PR's version of the race test also issues requests while the timeout is being written, so it exercises the read path inside 🤖 Generated with Claude Code |
Closes #38
The defect
WithTimeoutwrote straight through toClient.httpClient.Timeout(client.go:118). When the caller supplied their own client viaWithHTTPClient, that pointer was theirs — so the option reconfigured an object the SDK does not own.Verified still reproducing on
main(cd51e3a) before any change was written. Two distinct failures:main*http.Client;WithTimeout(1ns)on the first; the second client's timeout read 1ns (want 30s)-racereports a read/write race:net/http.(*Client).doatnet/http/client.go:608vsWithTimeoutatclient.go:118Both are in the P2 category because they are silent. The contaminated sibling does not error at construction — it fails later, as a timeout on an unrelated request, in a place with no connection to the line that caused it.
The fix
NewClientresolves the HTTP client once, after the options have run, into a struct the SDK owns (ownedHTTPClient). A supplied client is shallow-copied:Transport,JarandCheckRedirectcarry over and stay shared — that is what the caller supplied it for, and connection pooling must not be thrown away — whileTimeout, the only field the SDK sets, lives on a struct nobody else holds.Two contract clarifications fall out, both covered by tests:
WithTimeoutwins over a supplied client'sTimeout, in either option order. Previously whichever option ran last won, soNewClient(k, WithTimeout(5s), WithHTTPClient(hc))andNewClient(k, WithHTTPClient(hc), WithTimeout(5s))disagreed. This is a behaviour change; it is the direction that matches the documented intent ofWithTimeout.WithTimeout(0)still means "no timeout".timeoutSetis tracked separately fromtimeoutso the zero value is not misread as "unset" and silently restored toDefaultTimeout.A supplied client with no explicit
WithTimeoutkeeps its ownTimeout— the SDK does not imposeDefaultTimeouton a client the caller configured.Red
Tests written first, run with
-raceagainst unmodifiedmain:The race test drives the real path — a real
httptestserver,GetLatestPrices,httpClient.Do— rather than asserting on a field, so it is the race detector that decides, not the test author.Green
Full suite
go test ./...ok github.com/OilpriceAPI/oilpriceapi-go 16.550s— 294 pass / 0 fail (285 baseline + 9 new)go test -race ./...ok github.com/OilpriceAPI/oilpriceapi-go 18.371sgo vet ./...gofmt -l .Interaction with #41
The #41 PR (
fix/nil-http-client) guardsWithHTTPClient(nil). The two branches touch different functions and do not conflict in either merge order.ownedHTTPClienthere also handles a nil supplied client, so a nil is safe once either lands; #41 remains the explicit statement of that contract.Scope
No version bump, no tag, no release.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo