Skip to content

fix: add default HTTP timeout and retry transient connection errors#52

Merged
clifton merged 1 commit into
mainfrom
fix/default-timeout-retryable-errors
Jun 10, 2026
Merged

fix: add default HTTP timeout and retry transient connection errors#52
clifton merged 1 commit into
mainfrom
fix/default-timeout-retryable-errors

Conversation

@clifton

@clifton clifton commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes two related reliability hazards in all four provider clients (OpenAI, Anthropic, Gemini, Grok):

1. No default HTTP timeout — a hung connection blocked materialize() forever

Every client defaulted to timeout: None, and reqwest's default is no timeout at all, so a stalled connection (half-open TCP, unresponsive proxy, blackholed route) would hang a materialize()/generate() call indefinitely. Worse, the configured timeout only reached the reqwest client when .timeout() was explicitly called — the constructors built a bare reqwest::Client::new().

Now:

  • All clients default to a 5-minute total request timeout (DEFAULT_REQUEST_TIMEOUT = 300s — generous enough for reasoning models) plus a 30-second connect timeout (DEFAULT_CONNECT_TIMEOUT = 30s).
  • Both constants are defined once in a shared build_http_client() helper (and re-exported as rstructor::DEFAULT_REQUEST_TIMEOUT / rstructor::DEFAULT_CONNECT_TIMEOUT), used by every constructor so the default actually reaches the reqwest client.
  • The .timeout() builder still overrides the request timeout, and now also preserves the connect timeout when it rebuilds the client.
  • Custom base_url paths use the same client and inherit the timeouts; MockClient is offline and unaffected.

2. Transient connection errors were fatal while 502s retried

RStructorError::is_retryable() returned true for retryable ApiError kinds and Timeout, but false for everything else. reqwest connection-reset/refused/DNS failures map to RStructorError::HttpError and were therefore never retried — even though they are exactly as transient as the 5xx errors that DO retry.

Now: is_retryable() (and retry_delay()) return true / Some(1s) for HttpError(e) where e.is_connect() || e.is_timeout(). Body/decode errors remain non-retryable. The existing retry loop in generate_with_retry_with_initial_messages consults is_retryable() and picks these up automatically.

⚠️ Behavior change

Requests that previously could run (or hang) indefinitely now fail with RStructorError::Timeout after 5 minutes (30s if the connection can't even be established). Restore the old behavior with an explicit long .timeout(...) if needed. Additionally, transient connection failures are now retried by the built-in retry loop instead of failing fast.

Tests

  • connection_refused_error_is_retryable — local bind-then-drop ephemeral port; asserts the converted error stays HttpError, classifies retryable, and carries a retry delay (no external network).
  • request_timeout_applies_and_maps_to_timeout_error — local listener that accepts but never responds; proves the default-built client's timeout actually fires and maps to RStructorError::Timeout.
  • non_transient_http_error_is_not_retryable — a non-connect/non-timeout reqwest error remains non-retryable.
  • default_config_has_default_timeout / explicit_timeout_overrides_default on all four provider clients.
  • Full suite: 605 tests pass with --all-features; cargo clippy --all-targets (default features) and --all-features are clean; doctests pass. No new tests hit live APIs.

🤖 Generated with Claude Code

Two related reliability fixes:

1. Default HTTP timeout. All four provider clients (OpenAI, Anthropic,
   Gemini, Grok) previously defaulted to no timeout at all - reqwest's
   default - so a hung connection could block materialize() forever.
   Clients now default to a 5-minute total request timeout
   (DEFAULT_REQUEST_TIMEOUT) plus a 30-second connect timeout
   (DEFAULT_CONNECT_TIMEOUT), built once via a shared
   build_http_client() helper so the default actually reaches the
   reqwest client (previously the timeout was only applied when
   .timeout() was explicitly called). The .timeout() builder override
   still works and now also preserves the connect timeout.

2. Retryable connection errors. RStructorError::is_retryable() returned
   false for HttpError, so connection-reset/refused/DNS failures were
   treated as fatal even though they are exactly as transient as the
   5xx errors that DO retry. is_retryable() (and retry_delay()) now
   return true / Some(1s) for HttpError where the underlying reqwest
   error is_connect() or is_timeout(). Body/decode errors remain
   non-retryable.

New tests cover: connection-refused classifying as retryable after
conversion, the request timeout actually firing through the built
client, non-transient HTTP errors staying non-retryable, and default
vs explicitly-set timeouts on all four provider configs.

Co-Authored-By: Claude Fable 5 <[email protected]>
@clifton clifton force-pushed the fix/default-timeout-retryable-errors branch from 3c3ad50 to e88bfad Compare June 10, 2026 04:39
@clifton clifton merged commit 4612f76 into main Jun 10, 2026
5 checks passed
@clifton clifton deleted the fix/default-timeout-retryable-errors branch June 10, 2026 04:39
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