Skip to content

Add configurable request timeouts via Config type#4

Merged
hellerve merged 2 commits into
mainfrom
claude/request-timeouts
Jun 1, 2026
Merged

Add configurable request timeouts via Config type#4
hellerve merged 2 commits into
mainfrom
claude/request-timeouts

Conversation

@carpentry-agent

Copy link
Copy Markdown

Summary

  • Adds a Config type with connect-timeout and read-timeout fields (in seconds, 0 = no timeout)
  • Adds -with-config variants for all public request functions: request-with-config, request-stream-with-config, get-with-config, post-with-config, put-with-config, del-with-config, patch-with-config
  • Adds Connection.set-timeout as a dispatch helper over plain/TLS streams
  • Connect-timeout uses TcpStream.connect-timeout (plain HTTP only — TlsStream.connect does not support a timeout parameter)
  • Read-timeout uses set-timeout on both TcpStream and TlsStream

Usage

(let [cfg (Config.init 5 10)]  ; 5s connect, 10s read
  (match (Client.get-with-config "https://example.com/" &cfg)
    (Result.Success r) (println* (Response.body &r))
    (Result.Error e) (IO.errorln &e)))

Design notes

  • Existing API is untouched — all new functions, no signature changes
  • Config is passed by reference (&Config) so it can be reused across calls
  • The redirect loop in request-stream-with-config reuses the same config for each hop
  • Config.default creates a zero-timeout config equivalent to the existing behaviour

Tests

  • Config.default field values
  • get-with-config and post-with-config with default config
  • request-stream-with-config streaming variant
  • Redirect following with config
  • Generous read-timeout on fast response (no interference)
  • Read-timeout triggers error on slow response (httpbin /delay/10 with 2s timeout)

Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

Introduce a Config type with connect-timeout and read-timeout fields
(in seconds, 0 = no timeout). Add -with-config variants for all public
request functions: request-with-config, request-stream-with-config,
get-with-config, post-with-config, put-with-config, del-with-config,
and patch-with-config.

Connect-timeout uses TcpStream.connect-timeout for plain HTTP.
Read-timeout uses set-timeout on both TcpStream and TlsStream.
Also adds Connection.set-timeout as a dispatch helper.

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

Build & Tests

CI: ubuntu-latest passes, macos-latest still pending. No local Carp compiler available for independent build verification.

Findings

Checked out the branch and read the full http-client.carp, test file, and README.

Core implementation is sound. Ownership is clean — config is passed as &Config through the entire call chain, all field accessors use @(...) to copy the Int out. Timeouts are applied at the right points (after connect, before any reads). The redirect loop correctly reuses the same config reference on each hop. Documentation is thorough.

Specific findings:

1. Dead code: Connection.set-timeout is defined but never called.
connect-with-config calls TcpStream.set-timeout and TlsStream.set-timeout directly on the unwrapped streams. The Connection.set-timeout helper at lines 51-54 is unused. Either use it in connect-with-config (cleaner) or remove it.

2. ~80 lines of duplicated redirect logic.
request-stream-with-config (lines ~526-604) is a near-exact copy of request-stream-with-max-redirects (lines ~426-499). The only differences: calling build-and-send-with-config instead of build-and-send, and hardcoding default-max-redirects. If a bug is found in redirect handling, it must be fixed in two places. Consider refactoring into a single internal function that accepts both a config and max-redirects, with the public functions as thin wrappers.

3. Missing API surface: no way to combine config with custom max-redirects.
request-stream-with-config hardcodes default-max-redirects. Users who need both timeouts and a custom redirect limit have no option. Adding a request-stream-with-config-and-max-redirects (or folding max-redirects into Config) would close this gap.

4. No negative timeout validation.
connect-with-config checks > 0 before applying timeouts, so negative values silently behave like 0 (no timeout). This is arguably fine, but the doc string only says "0 means no timeout" — it should mention negative values too, or Config.init should clamp/reject them.

5. HTTPS connect-timeout is silently ignored.
When scheme is "https", connect-with-config calls TlsStream.connect without a timeout parameter. This IS documented in the doc string and README, which is good. But a user passing Config.init 5 10 for an HTTPS URL might not realize only the read-timeout is active. Not a bug, but worth a code comment near the TLS branch.

6. Missing test coverage:

  • No test for connect-timeout specifically (all config tests use connect-timeout=0).
  • No tests for del-with-config, put-with-config, or patch-with-config.
  • No edge case tests for negative timeouts.
  • The read-timeout test depends on httpbin.org/delay/10 — fragile but consistent with existing tests.

7. Pre-existing: unused path binding in both build-and-send and build-and-send-with-config. Not introduced by this PR, just carried forward.

Verdict: revise

The core timeout implementation is correct and well-documented, but:

  • macos CI is still pending — wait for it before merging.
  • Dead code (Connection.set-timeout) should be used or removed.
  • 80 lines of duplicated redirect logic is a real maintainability risk — refactor into a shared internal function.
  • Missing max-redirects + config combination leaves an API gap.

Fixing the dead code and duplication are the priority items. The missing edge-case tests are nice-to-have.

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

Apart from the comments raised by the review agent, I’d also like to point out that the type name Config is likely too generic.

- Rename Config to RequestConfig (hellerve: too generic)
- Add max-redirects field to RequestConfig
- Remove dead Connection.set-timeout
- Refactor duplicated redirect loop into shared request-stream- internal
- Remove redundant connect/build-and-send (config path handles both)
- Fix unused path binding in build-and-send
- Document negative timeout behavior (treated same as 0)
@carpentry-agent

Copy link
Copy Markdown
Author

Addressed all review feedback:

hellerveConfig name too generic:

  • Renamed ConfigRequestConfig everywhere (type, module, docs, tests, README).

carpentry-reviewer — dead code (Connection.set-timeout):

  • Removed. connect-with-config already called TcpStream.set-timeout/TlsStream.set-timeout directly.

carpentry-reviewer — ~80 lines duplicated redirect logic:

  • Extracted a shared request-stream- internal function. Both request-stream-with-max-redirects and request-stream-with-config are now thin wrappers. Also unified connect/build-and-send (the config path handles zero-timeout configs identically to the non-config path), removing the other source of duplication.

carpentry-reviewer — missing API surface (config + custom max-redirects):

  • Added max-redirects field to RequestConfig. RequestConfig.init now takes 3 args: connect-timeout, read-timeout, max-redirects. RequestConfig.default gives (0, 0, 10).

carpentry-reviewer — negative timeout documentation:

  • Updated RequestConfig doc to state "0 (or negative) means no timeout".

carpentry-reviewer — unused path binding:

  • Removed from build-and-send.

Net result: -101 lines, one redirect loop instead of two, one connect/build-and-send pair instead of two. Type-checks clean (carp --check passes; the C compilation failure on TM.tm_zone is pre-existing on main).

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

Build & Tests

Build: pass (carp --check http-client.carp — only pre-existing prn-for-TlsStream warnings)
Tests: cannot run locally (no Carp compiler on ARM/Linux)
CI: pass (macOS + Ubuntu, all tests green)

Prior feedback

Issue Status
Config name too generic (hellerve) Fixed ✓ — renamed to RequestConfig everywhere (type, module, docs, tests, README)
Dead code: Connection.set-timeout (round 1) Fixed ✓ — removed entirely. Timeouts applied directly via TcpStream.set-timeout/TlsStream.set-timeout in Client.connect (lines 236, 247, 258)
~80 lines duplicated redirect logic (round 1) Fixed ✓ — single request-stream- internal function (lines 384-458), both request-stream-with-max-redirects and request-stream-with-config are thin wrappers
Missing API surface: config + max-redirects (round 1) Fixed ✓ — max-redirects folded into RequestConfig (line 74)
Negative timeout documentation (round 1) Fixed ✓ — doc says "0 (or negative) means no timeout" (lines 65-66, README line 60)
Unused path binding (round 1) Fixed ✓ — removed from build-and-send (lines 257-261)

All six pieces of prior feedback have been genuinely addressed.

Findings

1. Redirect deduplication — verified correct

Traced the delegation chain:

  • request-stream- (line 384): the single redirect loop
  • request-stream-with-max-redirects (line 467): constructs RequestConfig.init 0 0 max-redirects, delegates
  • request-stream (line 479): delegates to request-stream-with-max-redirects with default-max-redirects
  • request-stream-with-config (line 495): passes caller's config straight through

All paths converge on the single loop. The old two-loop duplication is gone.

2. Config ownership — correct ✓

request-stream- takes &RequestConfig (borrow). Field access uses @(RequestConfig.max-redirects config) to copy out Ints. The reference is only read, never stored. request-stream-with-max-redirects creates a local cfg and borrows it — the local lives long enough. No ownership issues.

3. connect always takes config — correct ✓

Lines 230-250: plain-HTTP path checks connect-timeout > 0 to choose between TcpStream.connect-timeout and TcpStream.connect. HTTPS path correctly skips connect-timeout (documented limitation). Both paths apply read-timeout via set-timeout. The when guard ensures zero/negative timeouts are no-ops.

4. Minor: body-request-with-config duplicates Content-Length logic

body-request (lines 560-563) and body-request-with-config (lines 597-600) have identical Content-Length insertion logic. If this ever changes, both need updating. Could be deduplicated by having body-request delegate to body-request-with-config with a default config, mirroring how request-stream delegates to request-stream-with-max-redirects. Not blocking — the code is correct and the duplication is only 2 lines.

5. README and docs accurate ✓

API table lists all 16 public functions including the new -with-config variants. All signatures match the implementation. RequestConfig docs correctly describe the three-argument init and the TLS timeout limitation.

Verdict: merge

All prior feedback addressed. The refactoring is clean — one redirect loop instead of two, config ownership is correct, naming is consistent. The only nitpick is the minor Content-Length duplication, which is not blocking.

@hellerve hellerve marked this pull request as ready for review June 1, 2026 10:16
@hellerve hellerve merged commit 61fe056 into main Jun 1, 2026
2 checks passed
@hellerve hellerve deleted the claude/request-timeouts branch June 1, 2026 10:16
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