What is the difference between reqwest::ClientBuilder::timeout and connect_timeout for controlling request timeouts?

timeout sets a deadline for the entire HTTP request lifecycle (connection establishment, request sending, and response receiving), while connect_timeout specifically limits how long the client waits for the initial TCP connection to succeed. These timeouts operate at different layers: connect_timeout governs the TCP handshake before any HTTP data is exchanged, and timeout is an end-to-end deadline that covers everything from DNS resolution through receiving the complete response body. Using them together provides defense in depth—connect_timeout prevents hanging on unreachable hosts, while timeout ensures the overall operation doesn't exceed a maximum duration regardless of where delays occur.

The Request Lifecycle

use reqwest::Client;
use std::time::Duration;
 
// A complete HTTP request has multiple phases:
//
// 1. DNS Resolution - Convert hostname to IP address
// 2. TCP Connection - Establish TCP socket (connect_timeout covers this)
// 3. TLS Handshake - HTTPS only, negotiate encryption
// 4. HTTP Request - Send request headers and body
// 5. Server Processing - Server generates response
// 6. HTTP Response - Receive response headers and body
//
// timeout covers: ALL of the above
// connect_timeout covers: Only step 2 (TCP Connection)
 
fn request_lifecycle() {
    // Illustration:
    // 
    // Timeline: |--DNS--|--TCP--|--TLS--|--Request--|--Server--|--Response--|
    //                    ^       ^
    //                    |       |
    //            connect_timeout |
    //                          timeout starts from request initiation
    //                          and covers everything
}

Understanding which phase each timeout covers helps diagnose where delays occur.

Basic timeout Usage

use reqwest::Client;
use std::time::Duration;
 
fn basic_timeout() -> Result<(), Box<dyn std::error::Error>> {
    // timeout applies to the entire request
    let client = Client::builder()
        .timeout(Duration::from_secs(5))  // Total request time limit
        .build()?;
    
    // This request must complete within 5 seconds total
    // Including: DNS, connection, TLS, request, response
    let response = client
        .get("https://httpbin.org/delay/2")
        .send()?;
    
    println!("Status: {}", response.status());
    Ok(())
}
 
fn timeout_exceeded() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .timeout(Duration::from_secs(1))  // Very short timeout
        .build()?;
    
    // httpbin.org/delay/2 waits 2 seconds before responding
    // This will fail because total time > 1 second
    let result = client
        .get("https://httpbin.org/delay/2")
        .send();
    
    match result {
        Err(e) => {
            // Error: request timed out
            println!("Timeout error: {}", e);
        }
        Ok(_) => println!("Unexpected success"),
    }
    Ok(())
}

timeout is a cumulative deadline from request start to response completion.

Basic connect_timeout Usage

use reqwest::Client;
use std::time::Duration;
 
fn basic_connect_timeout() -> Result<(), Box<dyn std::error::Error>> {
    // connect_timeout applies only to TCP connection establishment
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(3))  // Connection phase only
        .build()?;
    
    // The connection must complete within 3 seconds
    // After connection, there's no time limit (unless timeout is also set)
    let response = client
        .get("https://httpbin.org/get")
        .send()?;
    
    println!("Status: {}", response.status());
    Ok(())
}
 
fn connect_timeout_exceeded() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .connect_timeout(Duration::from_millis(1))  // Very short
        .build()?;
    
    // Connecting to an unreachable or slow host
    // This might fail during connection establishment
    let result = client
        .get("http://10.255.255.1:8080/")  // Non-routable IP
        .send();
    
    match result {
        Err(e) => {
            println!("Connection error: {}", e);
        }
        Ok(_) => println!("Connected"),
    }
    Ok(())
}

connect_timeout only limits the time to establish a TCP connection.

Combining Both Timeouts

use reqwest::Client;
use std::time::Duration;
 
fn combined_timeouts() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(5))   // Max 5s to connect
        .timeout(Duration::from_secs(30))          // Max 30s total
        .build()?;
    
    // Now we have two layers of protection:
    // 1. If the server is unreachable, connect_timeout fails fast
    // 2. If the server is slow to respond, overall timeout limits total time
    
    let response = client
        .get("https://httpbin.org/delay/10")
        .send()?;
    
    println!("Status: {}", response.status());
    Ok(())
}
 
fn why_combine() {
    // Scenario 1: Host is unreachable
    // - connect_timeout fires after 5s
    // - timeout would also fire, but connect_timeout is faster
    // - Clearer error: "connection timed out" vs "request timed out"
    
    // Scenario 2: Host is reachable but slow
    // - Connection succeeds within 5s
    // - Request takes 40s total
    // - timeout fires at 30s
    // - Error: "request timed out"
    
    // Scenario 3: Everything works
    // - Connection: 1s
    // - Request/response: 20s
    // - Total: 21s < 30s, success
}

Using both timeouts provides granular control and clearer error messages.

Per-Request Timeouts

use reqwest::Client;
use std::time::Duration;
 
fn per_request_timeout() -> Result<(), Box<dyn std::error::Error>> {
    // Client has default timeouts
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(10))
        .timeout(Duration::from_secs(60))
        .build()?;
    
    // Override timeout for specific request
    let fast_response = client
        .get("https://httpbin.org/get")
        .timeout(Duration::from_secs(5))  // Override client default
        .send()?;
    
    // Override connect_timeout for specific request
    let patient_connect = client
        .get("https://slow-server.example.com/")
        .connect_timeout(Duration::from_secs(30))  // Longer for slow networks
        .send()?;
    
    // The RequestBuilder methods override client defaults
    // Use .timeout(None) to disable timeout for a specific request
    
    Ok(())
}

Individual requests can override client-level timeout settings.

Timeout Behavior Details

use reqwest::Client;
use std::time::Duration;
 
fn timeout_behavior() {
    // Key behaviors to understand:
    
    // 1. timeout starts when send() is called
    //    - Includes DNS resolution time
    //    - Includes connection time
    //    - Includes TLS handshake time
    //    - Includes sending request body time
    //    - Includes receiving response headers AND body
    
    // 2. connect_timeout starts during TCP connect
    //    - Only applies to TCP connection phase
    //    - NOT the TLS handshake (comes after)
    
    // 3. If timeout < connect_timeout, timeout wins
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(30))
        .timeout(Duration::from_secs(5))
        .build();
    // If connection takes 10s, timeout fires at 5s
    // connect_timeout never fires because timeout is tighter
    
    // 4. Timeouts and response bodies
    // - timeout applies to reading the response body too
    // - If server sends data slowly, timeout can fire during body read
}
 
async fn streaming_response() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .timeout(Duration::from_secs(10))
        .build()?;
    
    let response = client
        .get("https://httpbin.org/stream-bytes/1000000")
        .send()
        .await?;
    
    // Response headers received quickly, but body is streaming
    // timeout still applies while reading body
    
    let body = response.bytes().await?;
    // If reading takes > 10s total, this will fail
    
    Ok(())
}

timeout covers the entire async operation, including body streaming.

When connect_timeout Fires vs timeout Fires

use reqwest::Client;
use std::time::Duration;
 
fn error_diagnosis() {
    // Different timeout errors indicate different problems:
    
    // connect_timeout error:
    // - Server is down
    // - Firewall blocking connection
    // - Network unreachable
    // - Server overloaded (can't accept connections fast enough)
    
    // timeout error (after connection):
    // - Server processing is slow
    // - Response body is large
    // - Network bandwidth is limited
    // - Server is streaming slowly
    
    // Knowing which timeout fired helps diagnose the issue
}
 
#[tokio::main]
async fn diagnose_timeout() {
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(5))
        .timeout(Duration::from_secs(30))
        .build()
        .unwrap();
    
    match client.get("https://example.com/api").send().await {
        Ok(response) => println!("Success: {}", response.status()),
        Err(e) => {
            // Check error type for timeout cause
            if e.is_timeout() {
                // Could be connect_timeout or overall timeout
                // Error message usually indicates which one
                if e.to_string().contains("connect") {
                    println!("Connection failed - server unreachable?");
                } else {
                    println!("Request timed out - server too slow?");
                }
            } else if e.is_connect() {
                println!("Connection error - network issue?");
            } else {
                println!("Other error: {}", e);
            }
        }
    }
}

Error messages help distinguish between connection and overall timeouts.

No Timeout (Infinite Wait)

use reqwest::Client;
use std::time::Duration;
 
fn no_timeout() -> Result<(), Box<dyn std::error::Error>> {
    // By default, reqwest has a timeout of 30 seconds for the whole request
    // (As of recent versions - check your version's defaults)
    
    // Disable timeout entirely (wait forever)
    let client = Client::builder()
        .timeout(None)  // No overall timeout
        .build()?;
    
    // This can hang indefinitely if server doesn't respond
    // Use carefully - only when you have other ways to detect hangs
    
    // Disable connect_timeout (wait forever for connection)
    let client = Client::builder()
        .connect_timeout(None)
        .timeout(Duration::from_secs(60))
        .build()?;
    
    // This will wait indefinitely for TCP connection
    // Then timeout after 60s total
    
    Ok(())
}
 
fn when_no_timeout() {
    // Use None carefully, typically for:
    // - Long-polling endpoints
    // - Server-sent events (SSE)
    // - Very large file downloads with progress monitoring
    // - WebSocket upgrades
    
    // But consider using per-request timeout overrides instead
}

Setting None disables timeouts, which can be useful but risky.

Real-World Configuration Patterns

use reqwest::Client;
use std::time::Duration;
 
fn production_client() -> Result<Client, reqwest::Error> {
    // Typical production configuration
    Client::builder()
        .connect_timeout(Duration::from_secs(10))   // Allow slow DNS/network
        .timeout(Duration::from_secs(60))            // Reasonable total time
        .pool_idle_timeout(Duration::from_secs(90)) // Connection pooling
        .pool_max_idle_per_host(10)                  // Connection reuse
        .user_agent("my-app/1.0")
        .build()
}
 
fn api_client() -> Result<Client, reqwest::Error> {
    // Fast API calls - tighter timeouts
    Client::builder()
        .connect_timeout(Duration::from_secs(5))
        .timeout(Duration::from_secs(15))
        .build()
}
 
fn file_download_client() -> Result<Client, reqwest::Error> {
    // Long downloads - loose timeout
    Client::builder()
        .connect_timeout(Duration::from_secs(10))
        .timeout(Duration::from_secs(300))  // 5 minutes for large files
        .build()
}
 
fn streaming_client() -> Result<Client, reqwest::Error> {
    // SSE/WebSocket - long-lived connections
    Client::builder()
        .connect_timeout(Duration::from_secs(10))
        .timeout(None)  // Managed at application level
        .build()
}

Different use cases warrant different timeout configurations.

Interaction with Connection Pooling

use reqwest::Client;
use std::time::Duration;
 
fn connection_pooling() {
    // reqwest uses a connection pool by default
    // Pooled connections bypass connect_timeout on reuse
    
    // Timeline for new connection:
    // 1. Check pool - no matching connection
    // 2. DNS resolution
    // 3. TCP connect (connect_timeout applies)
    // 4. TLS handshake
    // 5. Add to pool for reuse
    
    // Timeline for pooled connection:
    // 1. Check pool - found matching connection
    // 2. Reuse existing connection (no connect_timeout)
    // 3. Send request
    
    // timeout always applies, even to pooled connections
    
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(5))
        .timeout(Duration::from_secs(30))
        .pool_idle_timeout(Duration::from_secs(60))  // Keep connections alive
        .build();
    
    // First request: connect_timeout + timeout apply
    // Subsequent requests: only timeout applies (connection is reused)
}

Connection pooling affects which timeouts apply to subsequent requests.

Testing Timeouts

use reqwest::Client;
use std::time::Duration;
 
#[tokio::test]
async fn test_connect_timeout() {
    let client = Client::builder()
        .connect_timeout(Duration::from_millis(100))
        .build()
        .unwrap();
    
    // Non-routable IP address
    let result = client
        .get("http://10.255.255.1:12345/")
        .send()
        .await;
    
    assert!(result.is_err());
    assert!(result.unwrap_err().is_timeout() || result.unwrap_err().is_connect());
}
 
#[tokio::test]
async fn test_request_timeout() {
    let client = Client::builder()
        .timeout(Duration::from_millis(500))
        .build()
        .unwrap();
    
    // httpbin delay endpoint
    let result = client
        .get("https://httpbin.org/delay/2")  // Waits 2 seconds
        .send()
        .await;
    
    assert!(result.is_err());
    assert!(result.unwrap_err().is_timeout());
}
 
#[tokio::test]
async fn test_successful_request() {
    let client = Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .unwrap();
    
    let result = client
        .get("https://httpbin.org/get")
        .send()
        .await;
    
    assert!(result.is_ok());
}

Testing timeout behavior requires endpoints that deliberately delay responses.

Handling Timeout Errors Gracefully

use reqwest::Client;
use std::time::Duration;
 
#[tokio::main]
async fn robust_request() -> Result<String, String> {
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(5))
        .timeout(Duration::from_secs(30))
        .build()
        .map_err(|e| format!("Client error: {}", e))?;
    
    match client.get("https://api.example.com/data").send().await {
        Ok(response) => {
            if response.status().is_success() {
                response
                    .text()
                    .await
                    .map_err(|e| format!("Body error: {}", e))
            } else {
                Err(format!("HTTP error: {}", response.status()))
            }
        }
        Err(e) => {
            if e.is_timeout() {
                Err("Request timed out - server may be overloaded".to_string())
            } else if e.is_connect() {
                Err("Connection failed - check network or server".to_string())
            } else {
                Err(format!("Request error: {}", e))
            }
        }
    }
}
 
#[tokio::main]
async fn retry_on_timeout() -> Result<String, Box<dyn std::error::Error>> {
    let client = Client::builder()
        .timeout(Duration::from_secs(10))
        .build()?;
    
    let url = "https://api.example.com/data";
    let mut retries = 3;
    
    loop {
        match client.get(url).send().await {
            Ok(response) => {
                return Ok(response.text().await?);
            }
            Err(e) if e.is_timeout() && retries > 0 => {
                retries -= 1;
                tokio::time::sleep(Duration::from_secs(1)).await;
                continue;
            }
            Err(e) => return Err(e.into()),
        }
    }
}

Graceful error handling distinguishes between timeout types and enables retry strategies.

Synthesis

Quick reference:

use reqwest::Client;
use std::time::Duration;
 
fn quick_reference() {
    // connect_timeout: TCP connection establishment only
    // - Limits time to complete TCP handshake
    // - Doesn't cover TLS, request, or response
    // - Use to fail fast on unreachable hosts
    
    // timeout: Entire request lifecycle
    // - Covers DNS, connection, TLS, request, response
    // - Includes reading response body
    // - Use to limit total operation time
    
    // Typical configuration:
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(5))   // Fast fail on bad hosts
        .timeout(Duration::from_secs(30))          // Reasonable total limit
        .build();
    
    // Per-request override:
    client.get("url")
        .timeout(Duration::from_secs(60))          // Override for slow endpoint
        .connect_timeout(Duration::from_secs(10))  // Override connection time
        .send();
    
    // Disable:
    client.get("url")
        .timeout(None)           // No timeout (risky)
        .connect_timeout(None)    // No connection timeout (risky)
        .send();
}

Key insight: connect_timeout and timeout operate at different layers of the network stack, giving you fine-grained control over where failures can occur. connect_timeout is narrowly scoped to the TCP handshake—it's your defense against hanging on unresponsive or unreachable hosts. When the connection succeeds but the server is slow, connect_timeout doesn't help; that's where timeout applies, covering the entire request from DNS through response body. Setting both timeouts is a defensive pattern: use a relatively short connect_timeout (5-10 seconds typically) to fail fast on network issues, and a longer timeout (30-60 seconds or more) to handle slow but reachable servers. The error type when timeouts fire helps diagnose the root cause: connection failures suggest infrastructure issues (DNS, firewall, server down), while overall timeouts suggest application issues (slow processing, large responses, constrained bandwidth). For long-running operations like file downloads or streaming, consider disabling or significantly extending timeout while keeping connect_timeout to ensure you don't wait forever for an unreachable server.