How does reqwest::Client::execute handle automatic retries and redirect following?

reqwest::Client::execute handles redirect following automatically by default, following up to 10 consecutive redirects and preserving the method and body for appropriate redirect types (301/302/303 with GET, 307/308 preserving the original method), while automatic retries must be explicitly configured through the client builder and require the retry feature flag. Redirects are enabled by default because they're ubiquitous in web APIs and browsers—when a server responds with 3xx status codes, the client automatically follows to the new location. Retries, however, are opt-in because retrying a request can have side effects: a POST that creates a resource shouldn't be automatically retried just because the network timed out. The ClientBuilder provides redirect policy configuration for redirect behavior and requires enabling the __internal_retry_after feature or using middleware like tower-http for retry functionality.

Basic Request Execution

use reqwest::Client;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // Build a request
    let request = client
        .get("https://httpbin.org/get")
        .build()?;
    
    // Execute the request
    let response = client.execute(request).await?;
    
    println!("Status: {}", response.status());
    println!("Headers: {:?}", response.headers());
    
    let body = response.text().await?;
    println!("Body: {}", body);
    
    Ok(())
}

Client::execute sends the request and returns the response.

Default Redirect Behavior

use reqwest::{Client, RedirectPolicy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Default client follows redirects automatically
    let client = Client::new();
    
    // This URL redirects
    let response = client
        .get("https://httpbin.org/redirect/2")
        .send()
        .await?;
    
    // response is from the final URL after following redirects
    println!("Final URL: {:?}", response.url());
    println!("Status: {}", response.status());
    
    Ok(())
}

By default, reqwest follows up to 10 redirects.

Redirect Policy Configuration

use reqwest::{Client, RedirectPolicy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // No redirects
    let no_redirects = Client::builder()
        .redirect(RedirectPolicy::none())
        .build()?;
    
    // Limited redirects
    let limited = Client::builder()
        .redirect(RedirectPolicy::limited(5))
        .build()?;
    
    // Unlimited redirects (be careful of loops!)
    let unlimited = Client::builder()
        .redirect(RedirectPolicy::limited(100))
        .build()?;
    
    // Custom redirect policy
    let custom = Client::builder()
        .redirect(RedirectPolicy::custom(|attempt| {
            // Stop after 3 redirects
            if attempt.previous().len() >= 3 {
                attempt.stop()
            } else {
                attempt.follow()
            }
        }))
        .build()?;
    
    Ok(())
}

RedirectPolicy controls redirect behavior: none, limited, or custom.

Custom Redirect Logic

use reqwest::{Client, RedirectPolicy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .redirect(RedirectPolicy::custom(|attempt| {
            let url = attempt.url().clone();
            println!("Redirect to: {}", url);
            
            // Don't follow redirects to different hosts
            if attempt.previous().len() > 0 {
                let original_host = attempt.previous()[0].host_str();
                let new_host = url.host_str();
                
                if original_host != new_host {
                    println!("Refusing cross-host redirect");
                    return attempt.stop();
                }
            }
            
            // Don't follow more than 5 redirects
            if attempt.previous().len() >= 5 {
                return attempt.stop();
            }
            
            attempt.follow()
        }))
        .build()?;
    
    let response = client
        .get("https://httpbin.org/redirect/3")
        .send()
        .await?;
    
    println!("Final status: {}", response.status());
    
    Ok(())
}

Custom policies allow fine-grained control over redirect following.

Redirect History

use reqwest::Client;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    let response = client
        .get("https://httpbin.org/redirect/3")
        .send()
        .await?;
    
    // Access the redirect chain
    println!("Final URL: {}", response.url());
    
    // The response contains the final URL
    // To see the redirect chain, use redirect policy with tracking
    // or check history manually
    
    Ok(())
}

The Response::url() gives the final URL after redirects.

HTTP Status Codes and Redirect Handling

use reqwest::Client;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // 301/302/303: Follow with GET (drop body)
    // 307/308: Follow with same method and body
    
    // POST with 302 redirect
    let response = client
        .post("https://httpbin.org/status/302")
        .body("original body")
        .send()
        .await?;
    
    // The redirect is followed, method becomes GET
    println!("Status: {}", response.status());
    
    // 307 preserves the method
    let response = client
        .post("https://httpbin.org/redirect-to?url=https://httpbin.org/post&status_code=307")
        .body("preserved body")
        .send()
        .await?;
    
    // Method remains POST, body preserved
    println!("Status after 307: {}", response.status());
    
    Ok(())
}

Different redirect codes affect how the method and body are handled.

Redirect Body Handling

use reqwest::Client;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // For 301/302/303, the body is dropped and method becomes GET
    let response = client
        .post("https://httpbin.org/redirect-to?url=https://httpbin.org/get")
        .json(&serde_json::json!({ "key": "value" }))
        .send()
        .await?;
    
    println!("Final method: {:?}", response.remote_addr());
    
    // For 307/308, the method and body are preserved
    let response = client
        .post("https://httpbin.org/redirect-to?url=https://httpbin.org/post&status_code=307")
        .json(&serde_json::json!({ "key": "value" }))
        .send()
        .await?;
    
    println!("307 redirect status: {}", response.status());
    
    Ok(())
}

301/302/303 convert to GET; 307/308 preserve the original method.

No Built-in Retry by Default

use reqwest::Client;
use std::time::Duration;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // This request will fail once, and reqwest will NOT retry
    // The error is returned immediately
    let result = client
        .get("https://httpbin.org/delay/10")  // Takes 10 seconds
        .timeout(Duration::from_secs(1))
        .send()
        .await;
    
    match result {
        Ok(response) => println!("Success: {}", response.status()),
        Err(e) => println!("Error (not retried): {}", e),
    }
    
    Ok(())
}

By default, reqwest does NOT retry failed requests.

Retry with Middleware

use reqwest::Client;
use reqwest_retry::{RetryTransientMiddleware, RetryPolicy};
use std::time::Duration;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Use reqwest-retry crate for retry functionality
    let retry_policy = RetryPolicy::builder()
        .retry(3)
        .build();
    
    let client = reqwest_middleware::ClientBuilder::new(Client::new())
        .with(RetryTransientMiddleware::new_with_policy(retry_policy))
        .build();
    
    let response = client
        .get("https://httpbin.org/get")
        .send()
        .await?;
    
    println!("Status: {}", response.status());
    
    Ok(())
}

Retry functionality requires external crates like reqwest-retry or tower-http.

Manual Retry Implementation

use reqwest::Client;
use std::time::Duration;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    let max_retries = 3;
    let url = "https://httpbin.org/get";
    
    let mut attempts = 0;
    let response = loop {
        attempts += 1;
        
        match client.get(url).send().await {
            Ok(response) => break response,
            Err(e) => {
                if attempts >= max_retries {
                    return Err(e.into());
                }
                
                // Check if error is retryable
                if e.is_timeout() || e.is_connect() {
                    println!("Attempt {} failed: {}, retrying...", attempts, e);
                    tokio::time::sleep(Duration::from_millis(100 * attempts as u64)).await;
                    continue;
                }
                
                // Non-retryable error
                return Err(e.into());
            }
        }
    };
    
    println!("Success after {} attempts", attempts);
    
    Ok(())
}

Manual retry logic gives full control over retry conditions.

Retryable vs Non-Retryable Errors

use reqwest::Client;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // Retryable errors:
    // - Timeout (is_timeout())
    // - Connection errors (is_connect())
    // - 5xx status codes (server errors)
    
    // Non-retryable errors:
    // - 4xx status codes (client errors)
    // - Invalid URL
    // - Invalid headers
    
    let result = client
        .get("https://httpbin.org/status/500")  // Server error
        .send()
        .await?;
    
    // 500 is a valid response, not an error
    // To treat 5xx as errors, check status
    if result.status().is_server_error() {
        println!("Server error (could retry)");
    }
    
    Ok(())
}

Distinguish between transport errors (retryable) and response errors (may not be retryable).

Retry with Exponential Backoff

use reqwest::Client;
use std::time::Duration;
 
async fn fetch_with_retry(
    client: &Client,
    url: &str,
    max_retries: u32,
) -> Result<reqwest::Response, reqwest::Error> {
    let mut attempts = 0;
    
    loop {
        match client.get(url).send().await {
            Ok(response) => {
                // Check status code
                if response.status().is_server_error() && attempts < max_retries {
                    attempts += 1;
                    let delay = Duration::from_millis(100 * 2u64.pow(attempts));
                    tokio::time::sleep(delay).await;
                    continue;
                }
                return Ok(response);
            }
            Err(e) => {
                attempts += 1;
                
                if attempts >= max_retries || !is_retryable(&e) {
                    return Err(e);
                }
                
                let delay = Duration::from_millis(100 * 2u64.pow(attempts));
                println!("Attempt {} failed, retrying in {:?}", attempts, delay);
                tokio::time::sleep(delay).await;
            }
        }
    }
}
 
fn is_retryable(error: &reqwest::Error) -> bool {
    error.is_timeout() || error.is_connect() || error.is_request()
}
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    let response = fetch_with_retry(&client, "https://httpbin.org/get", 3).await?;
    println!("Status: {}", response.status());
    Ok(())
}

Exponential backoff prevents overwhelming a struggling server.

Redirect with Sensitive Headers

use reqwest::{Client, header};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // Authorization header is NOT sent on cross-origin redirects
    let response = client
        .get("https://httpbin.org/bearer")
        .header(header::AUTHORIZATION, "Bearer secret-token")
        .send()
        .await?;
    
    // reqwest automatically strips sensitive headers on cross-host redirects
    
    println!("Status: {}", response.status());
    
    Ok(())
}

Sensitive headers like Authorization are stripped on cross-origin redirects.

Redirect to Relative URLs

use reqwest::Client;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // Relative redirects are resolved against the original URL
    let response = client
        .get("https://httpbin.org/relative-redirect/2")
        .send()
        .await?;
    
    println!("Final URL: {}", response.url());
    println!("Status: {}", response.status());
    
    Ok(())
}

Relative redirect URLs are resolved against the previous URL.

Redirect Loops

use reqwest::{Client, RedirectPolicy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Default policy limits to 10 redirects
    // This prevents infinite loops
    
    let client = Client::new();
    
    // If server returns infinite redirects, client stops after 10
    let result = client
        .get("https://httpbin.org/redirect/15")  // Would need 15 redirects
        .send()
        .await;
    
    match result {
        Ok(_) => println!("Success (unlikely)"),
        Err(e) => println!("Error (too many redirects): {}", e),
    }
    
    // With custom limit
    let client = Client::builder()
        .redirect(RedirectPolicy::limited(5))
        .build()?;
    
    let result = client
        .get("https://httpbin.org/redirect/10")
        .send()
        .await;
    
    match result {
        Ok(_) => println!("Success"),
        Err(e) => println!("Too many redirects: {}", e),
    }
    
    Ok(())
}

RedirectPolicy::limited prevents infinite redirect loops.

Combining Redirects and Retries

use reqwest::{Client, RedirectPolicy};
use std::time::Duration;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .redirect(RedirectPolicy::limited(10))
        .timeout(Duration::from_secs(30))
        .build()?;
    
    // Function that handles both redirects and retries
    async fn fetch(client: &Client, url: &str, max_retries: u32) -> Result<reqwest::Response, reqwest::Error> {
        let mut attempts = 0;
        
        loop {
            match client.get(url).send().await {
                Ok(response) => return Ok(response),
                Err(e) => {
                    attempts += 1;
                    if attempts >= max_retries {
                        return Err(e);
                    }
                    tokio::time::sleep(Duration::from_secs(1)).await;
                }
            }
        }
    }
    
    let response = fetch(&client, "https://httpbin.org/get", 3).await?;
    println!("Status: {}", response.status());
    
    Ok(())
}

Redirects are handled automatically; retries must be implemented manually or via middleware.

Checking Redirect Status

use reqwest::{Client, RedirectPolicy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Disable redirects to see the redirect response
    let client = Client::builder()
        .redirect(RedirectPolicy::none())
        .build()?;
    
    let response = client
        .get("https://httpbin.org/redirect/1")
        .send()
        .await?;
    
    // See the actual 302 response
    println!("Status: {}", response.status());
    
    if response.status().is_redirection() {
        println!("Location: {:?}", response.headers().get("location"));
        
        // Manually follow if needed
        if let Some(location) = response.headers().get("location") {
            let new_url = location.to_str()?;
            println!("Would redirect to: {}", new_url);
        }
    }
    
    Ok(())
}

Disabling redirects lets you inspect redirect responses directly.

Redirect with Request Body

use reqwest::{Client, RedirectPolicy};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    
    // POST with body to a redirecting URL
    let response = client
        .post("https://httpbin.org/redirect-to?url=https://httpbin.org/post")
        .json(&serde_json::json!({ "data": "test" }))
        .send()
        .await?;
    
    // For 301/302/303, method becomes GET and body is lost
    println!("Final status: {}", response.status());
    
    // For 307/308, preserve method and body
    let response = client
        .post("https://httpbin.org/redirect-to?url=https://httpbin.org/post&status_code=307")
        .json(&serde_json::json!({ "data": "preserved" }))
        .send()
        .await?;
    
    println!("307 status: {}", response.status());
    
    Ok(())
}

Be aware of how redirects affect request bodies.

Synthesis

Redirect behavior by status code:

Status Code Method Change Body Handling
301 (Moved) GET Dropped
302 (Found) GET Dropped
303 (See Other) GET Dropped
307 (Temp Redirect) Preserved Preserved
308 (Perm Redirect) Preserved Preserved

Built-in vs. requires middleware:

Feature Built-in Requires Setup
Redirect following Yes (default) RedirectPolicy::none() to disable
Redirect limits Yes (10) RedirectPolicy::limited(n)
Retry on error No Requires reqwest-retry or manual
Exponential backoff No Requires implementation
Status-based retry No Requires implementation

When redirects happen automatically:

Scenario Behavior
Response is 3xx Follow Location header
Cross-host redirect Strip sensitive headers
Relative Location Resolve against previous URL
Too many redirects Return error

Key insight: reqwest::Client::execute handles redirect following as a built-in, configurable feature because redirects are a standard HTTP mechanism where the server explicitly tells the client to look elsewhere—there's no ambiguity about whether to follow, only how many hops to allow. Retries, however, are opt-in because they require application-specific logic: should a POST be retried after a timeout? What about a 503 Service Unavailable? The answer depends on whether the operation is idempotent, whether the server acknowledged receipt, and what consistency guarantees the application needs. The redirect policy lets you say "stop after N redirects" or "don't follow cross-host redirects," but retry logic requires you to decide which errors are transient, how many times to retry, and how long to wait between attempts. The reqwest-retry crate provides sensible defaults (retry on 5xx and network errors, use exponential backoff), but the ultimate control lives in your code—because only you know whether retrying a request is safe for your application's data integrity.