Skip to content

feat(go): expose connect/read/write timeouts - #17210

Open
cadesark wants to merge 2 commits into
mainfrom
cade/go-connect-read-write-timeouts
Open

feat(go): expose connect/read/write timeouts#17210
cadesark wants to merge 2 commits into
mainfrom
cade/go-connect-read-write-timeouts

Conversation

@cadesark

Copy link
Copy Markdown
Contributor

Description

Adds independent connect / read / write timeout options to the generated Go SDK. Ships as its own PR (Java #17207 and Python #17209 are the companions for the same feature). Mirrors their config shape and per-call approach in Go idiom.

Go has no timeout config today — timeout is entirely user-supplied via option.WithHTTPClient(&http.Client{Timeout: ...}) + context.WithTimeout. This PR adds an optional, additive timeouts block that, when set, builds a default *http.Client backed by a custom transport.

Changes Made

  • Config (generators.yml config:): optional, additive nested timeouts block (connect/read/write in seconds, fractional allowed). Non-negative validation. Parsed in Go v1 (cmd.go -> config.go) and accepted by the v2 Zod schema so its strict validation does not reject it.
  • Base client (v1 core/request_option.go): when timeouts is set and the caller has not supplied their own HTTP client, NewRequestOptions injects a default *http.Client built from a custom transport:
    • connect -> net.Dialer.Timeout (+ http.Transport.TLSHandshakeTimeout). Clean.
    • read -> http.Transport.ResponseHeaderTimeout, plus an idle read deadline wrapping the response body to approximate a full body-read timeout.
    • write -> approximation: net/http has no first-class client write deadline, so a custom RoundTripper bounds the idle time between reads of the request body (which the transport performs while writing the body to the wire). Documented in the changelog.
  • Per-call overrides (v1 option/request_option.go): option.WithConnectTimeout / WithReadTimeout / WithWriteTimeout, which win over the configured defaults for the phases they cover.
  • Gating: all timeout code (fields, option structs, custom-transport runtime, With* helpers, extra imports) is emitted only when a timeouts block is present. When unset, generated output is byte-identical to before.
  • Changelog feat entry under generators/go/sdk/changes/unreleased/ noting the write-timeout (and body-read) approximation.
  • Updated README.md generator (if applicable) — n/a; the residual write approximation is documented in the changelog.

Testing

  • Unit tests added/updated — Go v1 (timeouts_test.go: IsConfigured, timeoutLiteral) and v2 Zod schema (timeouts.test.ts, 6 cases).
  • Manual testing completed:
    • Added a phase-timeouts variant to seed/go-sdk/examples (timeouts: {connect: 5, read: 30, write: 30}); regenerated all examples variants — 11/11 seed cases pass.
    • Confirmed fixtures without timeouts are byte-identical (only .fern/metadata.json local artifacts changed; reverted).
    • go build ./... and go vet ./... on the generated phase-timeouts seed both pass clean (the real gate — Go fails on unused imports / undefined symbols).
    • Runtime proof: a throwaway test against the generated core package confirmed the connect timeout fires (~100ms on a non-routable address), the read timeout fires (~500ms server delay), and resolveTimeout prefers the per-call override.

Notes / residual approximation

write and the body-read portion of read are approximations because net/http exposes no first-class client-side read/write socket deadlines. They are implemented via a custom RoundTripper + idle-deadline io.ReadCloser wrappers on the request/response bodies. connect (dialer + TLS handshake) and the response-header portion of read are exact.

Generated with Claude Code

Add an optional, additive `timeouts` block (connect/read/write in seconds,
fractional allowed) to the Go SDK generator config. When set — and the caller
has not supplied their own HTTP client — the generated SDK builds a default
*http.Client backed by a custom transport:

- connect -> net.Dialer.Timeout (+ http.Transport.TLSHandshakeTimeout). Clean.
- read    -> http.Transport.ResponseHeaderTimeout, plus an idle read deadline on
  the response body to approximate a full body-read timeout.
- write   -> net/http exposes no first-class client write deadline, so the SDK
  bounds the idle time between reads of the request body (which the transport
  performs while writing the body to the wire) to approximate it as closely as
  feasible. This residual approximation is documented in the changelog.

When `timeouts` is unset, generated output is byte-identical to before (no
default client is injected; the existing user-supplied *http.Client behavior is
preserved).

The same phases are overridable per request via `option.WithConnectTimeout`,
`option.WithReadTimeout`, and `option.WithWriteTimeout`, which win over the
configured defaults for the phases they cover.

The config/runtime lives in the Go v1 generator (core/request_option.go and
option/request_option.go); the v2 Zod schema accepts the field so its strict
config validation does not reject it. The custom-transport runtime and per-call
fields are only emitted when a `timeouts` block is present.

Co-Authored-By: Claude <noreply@anthropic.com>
@cadesark cadesark self-assigned this Jul 23, 2026

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

AI Review Summary

The PR adds opt-in connect/read/write timeouts to the Go SDK generator. The config plumbing and gating are sound, but the write-timeout runtime has a significant concurrency bug (goroutine leak / data race on the buffer) in the idle-read approximation, and there are a couple of correctness edge cases in the transport wiring.

  • 🔴 1 critical issue(s)
  • 🟡 2 warning(s)
  • 🔵 3 suggestion(s)

Comment on lines +1230 to +1244
func (r *idleTimeoutReadCloser) Read(p []byte) (int, error) {
done := make(chan readResult, 1)
go func() {
n, err := r.rc.Read(p)
done <- readResult{n: n, err: err}
}()
timer := time.NewTimer(r.timeout)
defer timer.Stop()
select {
case res := <-done:
return res.n, res.err
case <-timer.C:
return 0, &timeoutError{}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 critical

idleTimeoutReadCloser.Read leaks a goroutine and creates a data race on p. On timeout it returns without waiting for the spawned goroutine, which is still calling r.rc.Read(p). The caller (net/http) will reuse or read p while that goroutine writes into it → data race and buffer corruption. It also leaks a goroutine per timed-out read, and the goroutine blocks forever if the underlying Read never returns. This approximation is unsafe for the request body in particular, where net/http may retry/reuse buffers. Consider using http.NewRequestWithContext + a context deadline per phase, or at minimum abandon the buffer after timeout (never reuse p) and document the leak.

Comment on lines +1197 to +1213
if t.write > 0 && req.Body != nil {
req.Body = &idleTimeoutReadCloser{
rc: req.Body,
timeout: t.write,
}
}
resp, err := t.base.RoundTrip(req)
if err != nil {
return nil, err
}
if t.read > 0 && resp.Body != nil {
resp.Body = &idleTimeoutReadCloser{
rc: resp.Body,
timeout: t.read,
}
}
return resp, 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.

🟡 warning

RoundTrip mutates the incoming *http.Request by reassigning req.Body. Per the http.RoundTripper contract, RoundTrip must not modify the request. Under retries (the SDK has its own retrier) the same request may be reused, and wrapping an already-wrapped body compounds. Clone the request (req = req.Clone(req.Context())) before swapping the body.


func (e *timeoutError) Error() string { return "fern: i/o timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }`

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

Temporary() is deprecated on net.Error (Go 1.18+) and golangci-lint may flag it. Since the emitted seed runs go vet/lint in CI, consider dropping it or confirm the configured linter version doesn't error on the deprecation.

}
// Convert seconds to nanoseconds to preserve fractional precision, and emit
// a time.Duration literal (nanoseconds is time.Duration's base unit).
nanos := int64(*seconds * float64(time.Second))

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

int64(*seconds * float64(time.Second)) can overflow or lose precision for large values, and truncates rather than rounds. Minor, but int64(math.Round(*seconds * float64(time.Second))) would preserve fractional inputs more faithfully (e.g. 2.5s is exact here, but 0.1s isn't).

MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: connect,
ResponseHeaderTimeout: read,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

ResponseHeaderTimeout: read conflates two different semantics: read is documented as covering both response-header wait and per-read idle on the body. Setting the whole read budget as the header timeout means a slow-header + slow-body response effectively gets read for headers and another read per idle body read — the total can far exceed the user's intended read. Worth clarifying in docs that read is a per-phase idle bound, not a total.

if err := f.writeRequestOptionStructs(auth, headers, len(idempotencyHeaders) > 0, isMultiURL, inferredParams, serverURLVariablesFromConfig(f.serverURLVariables, environmentsConfig)); err != nil {
return err
}
f.writeTimeoutOptionStructsAndRuntime(timeouts)

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

f.writeTimeoutOptionStructsAndRuntime returns nothing but the other write helpers return errors that are checked. Fine since it can't fail, but if writeOptionStruct ever surfaces an error you're swallowing it here (_ = on lines 1126-1128). Consider propagating rather than discarding.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-23T05:02:46Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
go-sdk square 137s (n=5) 297s (n=5) 137s +0s (+0.0%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-07-23T05:02:46Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-23 18:14 UTC

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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