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 error

Phase 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.