How does reqwest::ClientBuilder::timeout affect both connection and request phases differently?
reqwest::ClientBuilder::timeout sets a total time limit that encompasses the entire HTTP operation—including DNS resolution, connection establishment, TLS handshake, request sending, and response body reading—meaning the timeout applies to the complete request lifecycle rather than individual phases. This single timeout value means that slow operations in any phase consume time from the same budget, creating implicit trade-offs between phases where a slow connection leaves less time for the response body.
The Single Timeout Behavior
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn single_timeout() -> Result<(), Box<dyn std::error::Error>> {
// timeout() sets ONE timeout for the entire operation
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
// This 30-second timeout covers:
// 1. DNS resolution
// 2. TCP connection establishment
// 3. TLS handshake (for HTTPS)
// 4. HTTP request sending
// 5. Waiting for response headers
// 6. Reading response body
let response = client.get("https://example.com/api/data")
.send()
.await?;
// If ANY phase takes too long, the total time exceeds 30 seconds,
// and the entire operation fails with a timeout error
Ok(())
}The timeout is a wall-clock budget for the entire HTTP operation from start to finish.
Connection Phase Timeout Impact
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn connection_timeout_impact() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
// Scenario: Slow server to connect to
// If connection takes 8 seconds:
// - 8 seconds consumed for connection
// - Only 2 seconds remaining for request + response
// This can cause unexpected failures:
let result = client.get("https://slow-server.example.com/large-response")
.send()
.await;
// If connection took 8 seconds and response body reading took 3 seconds:
// Total: 11 seconds > 10 second timeout
// Result: Timeout error, even though response body is small
// The timeout doesn't distinguish between "slow connection" and "slow response"
Ok(())
}A slow connection phase consumes time from the shared budget, leaving less time for the request phase.
Request Phase Timeout Impact
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn request_timeout_impact() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
// Fast connection, slow response
let response = client.get("https://fast-server.example.com/large-file")
.send()
.await?; // Connection: 0.5 seconds
// Now we're in the response phase
// If we try to read a large body:
let body = response.text().await?;
// The timeout applies to send() + text() combined
// If send() took 1 second and text() takes 29 seconds:
// Total: 30 seconds - exactly at limit
// If text() takes 30 seconds alone:
// Total: 31 seconds > 30 second timeout
// Result: Timeout error during body reading
Ok(())
}Response body reading is part of the same timeout budget, affecting large or slow responses.
The Key Distinction: What timeout() Covers
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn what_timeout_covers() {
// timeout() covers EVERYTHING from send() call to completion
let client = Client::builder()
.timeout(Duration::from_secs(60))
.build()
.unwrap();
// Timeline:
// T=0: send() called
// T=0-2: DNS resolution (part of timeout)
// T=2-5: TCP connection (part of timeout)
// T=5-7: TLS handshake (part of timeout)
// T=7-8: Send request headers/body (part of timeout)
// T=8-15: Wait for response headers (part of timeout)
// T=15-45: Read response body (part of timeout)
// T=45: Operation complete (within 60s timeout)
// If total time exceeds 60 seconds at any point:
// -> Timeout error
// The timeout is NOT per-phase
// It's a single budget consumed by all phases combined
}All phases share one timeout budget.
Separate Controls for Different Phases
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn separate_timeouts() -> Result<(), Box<dyn std::error::Error>> {
// reqwest provides separate timeout controls for specific phases
let client = Client::builder()
// connect_timeout: Only the connection establishment phase
.connect_timeout(Duration::from_secs(5))
// timeout: Total operation time (can be used WITH connect_timeout)
.timeout(Duration::from_secs(60))
.build()?;
// Now:
// - Connection must complete within 5 seconds
// If connection takes > 5s: Connection timeout error
// - Total operation must complete within 60 seconds
// If total time > 60s: Timeout error
// This separates concerns:
// - connect_timeout catches unresponsive servers quickly
// - timeout prevents long-running requests from hanging forever
Ok(())
}connect_timeout isolates the connection phase while timeout covers the total operation.
How connect_timeout Differs from timeout
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn connect_vs_total_timeout() {
// connect_timeout: Just the TCP connection establishment
let client = Client::builder()
.connect_timeout(Duration::from_secs(10))
.build()
.unwrap();
// connect_timeout applies to:
// - DNS resolution (if not cached)
// - TCP handshake (SYN, SYN-ACK, ACK)
// NOT to:
// - TLS handshake
// - Request sending
// - Response reading
// timeout: Everything
let client = Client::builder()
.timeout(Duration::from_secs(60))
.build()
.unwrap();
// timeout applies to:
// - Connection (if no connect_timeout)
// - TLS handshake
// - Request sending
// - Response header waiting
// - Response body reading
}connect_timeout is narrowly scoped; timeout is comprehensive.
Combining Both Timeouts
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn combined_timeouts() -> Result<(), Box<dyn std::error::Error>> {
// Best practice: Use both for different purposes
let client = Client::builder()
.connect_timeout(Duration::from_secs(5)) // Fast fail for bad servers
.timeout(Duration::from_secs(30)) // Total operation limit
.build()?;
// Scenario 1: Server unreachable
// - Connection fails after 5 seconds
// - Error: "connection timed out"
// - Total time: 5 seconds
// Scenario 2: Server slow to respond
// - Connection succeeds in 1 second
// - Response takes 29 seconds
// - Total: 30 seconds (within timeout)
// - Success
// Scenario 3: Very slow response
// - Connection succeeds in 1 second
// - Response takes 31 seconds
// - Total: 32 seconds > 30 second timeout
// - Error: "request timed out"
// Scenario 4: Slow connection + slow response
// - Connection takes 4 seconds (within connect_timeout)
// - Response takes 27 seconds
// - Total: 31 seconds > 30 second timeout
// - Error: "request timed out"
Ok(())
}Using both provides fast failure for connection issues and total operation limits.
TLS Handshake Timing Nuance
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn tls_handshake_timing() {
// TLS handshake is NOT part of connect_timeout
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
// No timeout set - TLS handshake could take forever!
.build()
.unwrap();
// For HTTPS:
// Phase 1: TCP connection (covered by connect_timeout)
// Phase 2: TLS handshake (NOT covered by connect_timeout)
// A slow TLS handshake with the above client could hang indefinitely
// Better: Use both timeouts for HTTPS
let client = Client::builder()
.connect_timeout(Duration::from_secs(5)) // TCP connection
.timeout(Duration::from_secs(30)) // Everything including TLS
.build()
.unwrap();
// Now TLS handshake is limited by the total timeout
}TLS handshake timing is a common oversight—connect_timeout doesn't cover it.
Request-Level Timeout Override
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn request_level_timeout() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(60)) // Client default
.build()?;
// Override timeout for specific request
let fast_response = client.get("https://api.example.com/quick")
.timeout(Duration::from_secs(5)) // Override: 5 seconds for this request
.send()
.await?;
// Another request with different timeout
let slow_response = client.get("https://api.example.com/slow-endpoint")
.timeout(Duration::from_secs(120)) // Override: 120 seconds for this request
.send()
.await?;
// Use client default
let normal_response = client.get("https://api.example.com/normal")
.send() // Uses client's 60-second timeout
.await?;
Ok(())
}Request-level timeout overrides client-level timeout for flexibility.
Streaming Response Bodies
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn streaming_timeout() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
// For streaming responses, timeout affects the entire stream
let response = client.get("https://example.com/large-file")
.send()
.await?;
// Response headers received - some timeout budget consumed
// Now stream the body
let mut stream = response.bytes_stream();
// Each chunk read consumes from the same timeout budget
use futures::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
// If total time exceeds 30 seconds here, timeout error
// Even if we've been streaming for 29 seconds already
println!("Received {} bytes", chunk.len());
}
// For long-running streams, timeout may cause premature failure
// Use .timeout(None) or very large timeout for streaming
Ok(())
}Streaming responses share the same timeout budget, which can cause issues for long streams.
No Timeout for Long Operations
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn no_timeout_for_long() -> Result<(), Box<dyn std::error::Error>> {
// For operations that legitimately take a long time
let client = Client::builder()
.timeout(None) // No timeout at all
.build()?;
// Useful for:
// - File uploads over slow connections
// - Large file downloads
// - Long-polling endpoints
// - Server-sent events (SSE)
// - WebSocket upgrades
// But also risky:
// - Requests can hang forever
// - No protection against unresponsive servers
// Better approach for long operations:
let client = Client::builder()
.connect_timeout(Duration::from_secs(10)) // Fail fast on bad connection
.timeout(Duration::from_secs(3600)) // 1 hour for long operations
.build()?;
// Or per-request override:
let response = client.get("https://example.com/long-poll")
.timeout(None) // Override: no timeout for this request
.send()
.await?;
Ok(())
}Long-running operations may need disabled or very long timeouts.
Connection Pooling and Timeout Interaction
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn connection_pool_timeout() {
// Connection pooling affects timeout behavior
let client = Client::builder()
.timeout(Duration::from_secs(30))
.pool_max_idle_per_host(5) // Keep connections alive
.build()
.unwrap();
// First request: Needs to establish connection
// Timeout includes: DNS + TCP + TLS + request + response
// Second request to same host: May reuse connection
// Timeout includes: Just request + response (no connection time!)
// This means:
// - Reused connections have more timeout budget for the request phase
// - Fresh connections have less timeout budget after connection phase
// Example:
// Request 1: Connection takes 5s, request takes 20s -> Total 25s (OK)
// Request 2: Reused connection, request takes 28s -> Total 28s (OK)
// Request 3: Fresh connection, 5s connection + 28s request = 33s (TIMEOUT!)
// Same timeout, different behavior based on connection reuse
}Connection reuse changes how timeout budget is distributed across phases.
Timeout Error Types
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn timeout_errors() {
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.build()
.unwrap();
let result = client.get("https://example.com/api")
.send()
.await;
match result {
Err(e) if e.is_timeout() => {
// This covers both:
// - Connection timeout (exceeded connect_timeout)
// - Request timeout (exceeded total timeout)
// To distinguish, check error message
let msg = e.to_string();
if msg.contains("connect") {
println!("Connection timed out");
} else {
println!("Request timed out");
}
}
Err(e) if e.is_connect() => {
println!("Connection failed: {}", e);
}
Err(e) => {
println!("Other error: {}", e);
}
Ok(response) => {
println!("Success: {}", response.status());
}
}
}Timeout errors don't distinguish between phases in the error type—check the message.
Practical Recommendations
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn best_practices() {
// Recommended configuration for most applications
let client = Client::builder()
// Connection timeout: Fast fail for unresponsive servers
.connect_timeout(Duration::from_secs(10))
// Total timeout: Prevent hung requests
.timeout(Duration::from_secs(60))
// Pool settings: Reuse connections for better timeout behavior
.pool_idle_timeout(Duration::from_secs(300))
.build()
.unwrap();
// For API calls (fast responses):
// - connect_timeout: 5-10 seconds
// - timeout: 30-60 seconds
// For file uploads/downloads:
// - connect_timeout: 10-30 seconds
// - timeout: Very large or None
// For streaming/SSE:
// - connect_timeout: 10 seconds
// - timeout: None (rely on application-level timeout)
// For health checks:
// - connect_timeout: 2-5 seconds
// - timeout: 5-10 seconds
}Configure timeouts based on expected operation duration and failure modes.
Complete Example
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn complete_example() -> Result<(), Box<dyn std::error::Error>> {
// Create client with both timeouts
let client = Client::builder()
.connect_timeout(Duration::from_secs(5)) // Connection phase
.timeout(Duration::from_secs(30)) // Total operation
.pool_max_idle_per_host(10)
.build()?;
// Scenario 1: Fast server, normal response
match client.get("https://httpbin.org/get").send().await {
Ok(response) => {
println!("Success: {}", response.status());
let body = response.text().await?;
println!("Body length: {} bytes", body.len());
}
Err(e) if e.is_timeout() => {
println!("Timeout: {}", e);
}
Err(e) => {
println!("Error: {}", e);
}
}
// Scenario 2: Slow endpoint with per-request override
match client.get("https://httpbin.org/delay/10")
.timeout(Duration::from_secs(15)) // Longer timeout for this request
.send()
.await
{
Ok(response) => {
println!("Slow endpoint succeeded: {}", response.status());
}
Err(e) if e.is_timeout() => {
println!("Slow endpoint timed out: {}", e);
}
Err(e) => {
println!("Error: {}", e);
}
}
// Scenario 3: Demonstrating timeout behavior
let start = std::time::Instant::now();
let result = client.get("https://httpbin.org/delay/40")
.timeout(Duration::from_secs(10))
.send()
.await;
let elapsed = start.elapsed();
match result {
Err(e) if e.is_timeout() => {
println!("Timed out after {:?}", elapsed);
// Should be close to 10 seconds, not 40
}
_ => {
println!("Unexpected result after {:?}", elapsed);
}
}
Ok(())
}Synthesis
Core behavior:
// timeout() applies to ENTIRE operation
Client::builder()
.timeout(Duration::from_secs(30))
.build();
// Timeline:
// T=0: send() called
// T=0-30: DNS + TCP + TLS + Request + Response (shared budget)
// T>30: Timeout errorPhase comparison:
| Phase | Covered by timeout |
Covered by connect_timeout |
|---|---|---|
| DNS resolution | Yes | Yes |
| TCP connection | Yes | Yes |
| TLS handshake | Yes | No |
| Request sending | Yes | No |
| Response headers | Yes | No |
| Response body | Yes | No |
Timeout budget flow:
send() called
│
├── DNS lookup (consumes timeout)
│
├── TCP connection (consumes timeout AND connect_timeout)
│
├── TLS handshake (consumes timeout ONLY)
│
├── Request sending (consumes timeout)
│
├── Response headers (consumes timeout)
│
└── Response body (consumes timeout)
If total time > timeout: ERROR
If TCP time > connect_timeout: ERROR
Best practices:
// Typical configuration
Client::builder()
.connect_timeout(Duration::from_secs(10)) // Fast fail on bad servers
.timeout(Duration::from_secs(60)) // Total operation limit
.build()
// Long operations
Client::builder()
.connect_timeout(Duration::from_secs(30)) // Still want fast fail
.timeout(Duration::from_secs(3600)) // 1 hour for uploads
.build()
// Streaming/SSE
Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(None) // No limit
.build()Key insight: reqwest::ClientBuilder::timeout is a single wall-clock budget for the entire HTTP operation, meaning slow connection establishment leaves less time for response body reading. This creates implicit coupling between phases that can cause confusing failures—a request that succeeds when connection pooling is warm (reused connection) might fail when the connection is fresh (slow handshake). The connect_timeout configuration provides phase-specific control for connection establishment, allowing fast failure for unresponsive servers while preserving more of the total timeout budget for the actual request and response phases. For operations where phase-specific timing matters, use connect_timeout for the TCP connection phase and timeout for the total operation, understanding that TLS handshake is part of the total timeout but not connect_timeout.
