How does reqwest::redirect::Policy::custom enable fine-grained redirect handling decisions?

reqwest::redirect::Policy::custom accepts a closure that receives each redirect attempt and returns a decision—follow the redirect, stop with an error, or ignore and return the current response—enabling application-specific logic for cross-origin redirects, authentication handling, and redirect loops. The closure receives detailed information about each redirect including the URL, status code, and previous URLs, allowing you to implement complex policies that go beyond the built-in limited and none policies.

Basic Custom Redirect Policy

use reqwest::redirect::Policy;
use reqwest::Url;
 
fn basic_custom_policy() {
    // Create a client with custom redirect handling
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // This closure is called for each redirect
            // attempt contains information about the redirect
            
            println!("Redirect from {} to {}", 
                attempt.previous().last().unwrap(),
                attempt.url()
            );
            
            // Return a decision: follow, stop, or don't follow
            attempt.follow()
        }))
        .build()
        .unwrap();
}

A custom policy receives each redirect attempt and decides whether to follow it.

Policy Decision Types

use reqwest::redirect::Policy;
 
fn policy_decisions() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // Three possible decisions:
            
            // 1. Follow the redirect
            // attempt.follow()
            
            // 2. Stop with an error
            // attempt.stop()
            
            // 3. Don't follow but return current response
            // attempt.do_not_follow()
            
            match attempt.url().scheme() {
                "https" => attempt.follow(),
                "http" => attempt.do_not_follow(),  // Keep HTTP response
                _ => attempt.stop(),                 // Error for other schemes
            }
        }))
        .build()
        .unwrap();
}

Three decisions are available: follow, stop with error, or skip redirect.

Cross-Origin Redirect Protection

use reqwest::redirect::Policy;
use reqwest::Url;
 
fn cross_origin_protection() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            let previous = attempt.previous();
            let new_url = attempt.url();
            
            // Check if redirect crosses origins
            let previous_origin = get_origin(previous.last().unwrap());
            let new_origin = get_origin(new_url);
            
            if previous_origin != new_origin {
                // Don't follow cross-origin redirects
                // Could log or handle specially
                eprintln!("Cross-origin redirect blocked: {} -> {}", 
                    previous_origin, new_origin);
                return attempt.stop();
            }
            
            attempt.follow()
        }))
        .build()
        .unwrap();
}
 
fn get_origin(url: &Url) -> String {
    format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""))
}

Block or log cross-origin redirects that could leak credentials.

Redirect Limit with Custom Logic

use reqwest::redirect::Policy;
 
fn redirect_limit() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // Custom limit based on redirect type
            let max_redirects = match attempt.status() {
                // Permanent redirects: allow more
                301 | 308 => 15,
                // Temporary redirects: allow fewer
                302 | 303 | 307 => 5,
                // Unknown: be conservative
                _ => 3,
            };
            
            if attempt.previous().len() >= max_redirects {
                eprintln!("Too many redirects: {}", attempt.previous().len());
                return attempt.stop();
            }
            
            attempt.follow()
        }))
        .build()
        .unwrap();
}

Implement custom redirect limits based on redirect type.

Preserving Headers on Redirect

use reqwest::redirect::Policy;
use reqwest::header::{AUTHORIZATION, HeaderMap};
 
fn sensitive_headers() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // Check if redirect is to different host
            let previous = attempt.previous().last().unwrap();
            let new_url = attempt.url();
            
            if previous.host() != new_url.host() {
                // Don't follow redirects to different hosts with sensitive headers
                // The client will strip Authorization headers automatically,
                // but you can add custom logic here
                if new_url.scheme() == "http" {
                    // Downgrade to HTTP - definitely don't follow with auth
                    return attempt.stop();
                }
            }
            
            attempt.follow()
        }))
        .build()
        .unwrap();
    
    // Note: reqwest automatically strips sensitive headers on cross-origin redirects
    // But custom policy gives you control over the decision
}

Control whether redirects to different hosts should be followed.

Domain Allowlist

use reqwest::redirect::Policy;
use url::Url;
 
fn domain_allowlist() {
    let allowed_domains = ["api.example.com", "cdn.example.com", "auth.example.com"];
    
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(move |attempt| {
            let new_host = attempt.url().host_str().unwrap_or("");
            
            if allowed_domains.contains(&new_host) {
                attempt.follow()
            } else {
                eprintln!("Redirect to disallowed domain: {}", new_host);
                attempt.stop()
            }
        }))
        .build()
        .unwrap();
}

Only follow redirects to trusted domains.

POST Request Redirect Handling

use reqwest::redirect::Policy;
 
fn post_redirect_handling() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // RFC 7231 specifies:
            // 301/302: Convert POST to GET (historically)
            // 303: Always convert to GET
            // 307/308: Preserve method (keep POST)
            
            match attempt.status() {
                303 => {
                    // 303 See Other - always GET
                    // reqwest handles this automatically
                    attempt.follow()
                }
                307 | 308 => {
                    // 307/308 preserve the method
                    // For POST requests, this keeps POST
                    attempt.follow()
                }
                301 | 302 => {
                    // Historical behavior: POST becomes GET
                    // You might want to stop for sensitive POSTs
                    attempt.follow()
                }
                _ => attempt.follow()
            }
        }))
        .build()
        .unwrap();
}

Handle different redirect status codes appropriately for POST requests.

Logging Redirect Chains

use reqwest::redirect::Policy;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
 
fn logging_redirects() {
    let redirect_count = Arc::new(AtomicUsize::new(0));
    let count_clone = redirect_count.clone();
    
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(move |attempt| {
            let current_count = count_clone.fetch_add(1, Ordering::SeqCst);
            
            println!("Redirect #{}: {} -> {}", 
                current_count + 1,
                attempt.previous().last().unwrap(),
                attempt.url()
            );
            
            // Log redirect details
            println!("  Status: {}", attempt.status());
            println!("  Method: {:?}", attempt.previous().len());
            
            attempt.follow()
        }))
        .build()
        .unwrap();
}

Log each redirect for debugging or auditing purposes.

Detecting Redirect Loops

use reqwest::redirect::Policy;
 
fn loop_detection() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            let current_url = attempt.url();
            
            // Check if we've been to this URL before
            for previous_url in attempt.previous() {
                if previous_url == current_url {
                    eprintln!("Redirect loop detected: {}", current_url);
                    return attempt.stop();
                }
            }
            
            // Also check for longer cycles (A -> B -> C -> A)
            // Previous contains the full chain
            
            attempt.follow()
        }))
        .build()
        .unwrap();
}

Detect and prevent redirect loops by checking visited URLs.

Conditional Redirect Based on Status

use reqwest::redirect::Policy;
 
fn status_based_policy() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            match attempt.status() {
                // Always follow temporary redirects
                302 | 303 | 307 => attempt.follow(),
                
                // Be cautious with permanent redirects
                301 | 308 => {
                    // Could log or verify permanent redirects
                    if attempt.url().scheme() == "https" {
                        attempt.follow()
                    } else {
                        // Don't permanently redirect to HTTP
                        attempt.stop()
                    }
                }
                
                // Unknown redirect status
                _ => attempt.stop()
            }
        }))
        .build()
        .unwrap();
}

Handle different redirect statuses with different policies.

Preserving Response on Redirect

use reqwest::redirect::Policy;
 
fn preserve_response() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // do_not_follow returns the redirect response
            // instead of following it
            
            // Useful for:
            // - Getting redirect URLs without following
            // - Handling redirects manually
            // - Checking headers on redirect response
            
            if attempt.url().path().starts_with("/api/") {
                // Don't follow API redirects automatically
                // Return the redirect response
                attempt.do_not_follow()
            } else {
                // Follow other redirects normally
                attempt.follow()
            }
        }))
        .build()
        .unwrap();
    
    // When do_not_follow is used, the response is the redirect response
    // You can still get the Location header manually
}

Use do_not_follow to get the redirect response without following.

Comparison with Built-in Policies

use reqwest::redirect::Policy;
 
fn built_in_policies() {
    // Policy::none() - Don't follow any redirects
    let client_no_redirects = reqwest::Client::builder()
        .redirect(Policy::none())
        .build()
        .unwrap();
    
    // Policy::limited(n) - Follow up to n redirects (default is 10)
    let client_limited = reqwest::Client::builder()
        .redirect(Policy::limited(5))
        .build()
        .unwrap();
    
    // Policy::custom() - Full control
    let client_custom = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // Any custom logic here
            attempt.follow()
        }))
        .build()
        .unwrap();
    
    // Built-in policies handle common cases:
    // - none: Return redirect responses as-is
    // - limited: Follow redirects up to a count
    // - custom: Full control for specific requirements
}

Built-in policies handle common cases; custom policies provide full control.

Accessing Redirect Attempt Information

use reqwest::redirect::Policy;
 
fn attempt_info() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            // Information available on the attempt:
            
            // The URL being redirected to
            let target_url: &reqwest::Url = attempt.url();
            
            // The history of URLs (all previous redirects)
            let history: &[reqwest::Url] = attempt.previous();
            
            // The redirect status code (301, 302, etc.)
            let status: reqwest::StatusCode = attempt.status();
            
            // The current response that triggered the redirect
            // Note: You can't access response body at this point
            
            println!("Redirect {} -> {}", status, target_url);
            println!("History length: {}", history.len());
            
            attempt.follow()
        }))
        .build()
        .unwrap();
}

The attempt provides URL, status, and history information.

Real-World Example: OAuth Flow

use reqwest::redirect::Policy;
 
fn oauth_flow() {
    // OAuth flows often have specific redirect handling needs
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            let url = attempt.url();
            
            // OAuth callback URLs
            if url.path().contains("/callback") {
                // Don't follow - return to the callback URL
                // The application needs to handle this
                return attempt.do_not_follow();
            }
            
            // External OAuth providers
            if url.host_str() == Some("accounts.google.com") {
                // Follow Google OAuth redirects
                return attempt.follow();
            }
            
            // Be careful with cross-site redirects during OAuth
            attempt.stop()
        }))
        .build()
        .unwrap();
}

OAuth flows require careful redirect handling for security.

Async Policy with State

use reqwest::redirect::Policy;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
 
fn policy_with_state() {
    let total_redirects = Arc::new(AtomicU64::new(0));
    let max_total = 100;
    
    let redirect_count = total_redirects.clone();
    
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(move |attempt| {
            // Track total redirects across all requests
            let current_total = redirect_count.fetch_add(1, Ordering::Relaxed);
            
            if current_total >= max_total {
                eprintln!("Global redirect limit reached");
                return attempt.stop();
            }
            
            attempt.follow()
        }))
        .build()
        .unwrap();
}

Share state across redirect decisions using Arc.

Security Considerations

use reqwest::redirect::Policy;
 
fn security_considerations() {
    let client = reqwest::Client::builder()
        .redirect(Policy::custom(|attempt| {
            let new_url = attempt.url();
            
            // 1. HTTPS downgrade protection
            let previous = attempt.previous().last().unwrap();
            if previous.scheme() == "https" && new_url.scheme() == "http" {
                eprintln!("HTTPS to HTTP redirect blocked");
                return attempt.stop();
            }
            
            // 2. Sensitive path protection
            let sensitive_paths = ["/admin", "/internal", "/debug"];
            for path in sensitive_paths {
                if previous.path().starts_with(path) {
                    // Don't leak sensitive URLs
                    eprintln!("Redirect from sensitive path blocked");
                    return attempt.stop();
                }
            }
            
            // 3. Local network protection
            if let Some(host) = new_url.host_str() {
                if host == "localhost" || host.starts_with("127.") || host.starts_with("192.168.") {
                    eprintln!("Redirect to local network blocked");
                    return attempt.stop();
                }
            }
            
            // 4. Redirect loop detection
            if attempt.previous().contains(new_url) {
                eprintln!("Redirect loop blocked");
                return attempt.stop();
            }
            
            attempt.follow()
        }))
        .build()
        .unwrap();
}

Implement security policies to prevent common redirect attacks.

Synthesis

Quick reference:

Method Behavior
attempt.follow() Follow the redirect
attempt.stop() Stop with error (too many redirects)
attempt.do_not_follow() Don't follow, return current response

Policy types:

Policy Behavior
Policy::none() Never follow redirects
Policy::limited(n) Follow up to n redirects
Policy::custom(fn) Custom decision for each redirect

Common patterns:

use reqwest::redirect::Policy;
 
fn patterns() {
    // Pattern 1: Limit with logging
    Policy::custom(|attempt| {
        println!("Redirect: {} -> {}", 
            attempt.previous().last().unwrap(),
            attempt.url()
        );
        if attempt.previous().len() > 10 {
            attempt.stop()
        } else {
            attempt.follow()
        }
    });
    
    // Pattern 2: Allowlist domains
    Policy::custom(|attempt| {
        let allowed = ["api.example.com", "cdn.example.com"];
        if allowed.contains(&attempt.url().host_str().unwrap_or("")) {
            attempt.follow()
        } else {
            attempt.stop()
        }
    });
    
    // Pattern 3: Don't follow to HTTP
    Policy::custom(|attempt| {
        if attempt.url().scheme() == "http" {
            attempt.stop()
        } else {
            attempt.follow()
        }
    });
}

Key insight: Policy::custom gives you fine-grained control over redirect decisions by passing a closure that receives a RedirectAttempt and returns a decision. The closure is called for each redirect in the chain, giving you access to the target URL (attempt.url()), the redirect status code (attempt.status()), and the full history of URLs (attempt.previous()). This enables you to implement security policies like blocking cross-origin redirects that could leak authentication headers, preventing HTTPS-to-HTTP downgrades, detecting redirect loops, implementing domain allowlists, and handling POST-to-GET method conversions. The three decisions—follow(), stop(), and do_not_follow()—let you either proceed with the redirect, terminate with an error, or return the redirect response without following. Use do_not_follow() when you need the redirect response itself (for example, to extract a Location header for manual handling), use stop() for error conditions like too many redirects or suspicious behavior, and use follow() for normal redirect handling. The built-in Policy::none() and Policy::limited(n) handle common cases, but Policy::custom is essential for security-sensitive applications, OAuth flows, API clients with specific redirect requirements, or any situation where the default behavior isn't appropriate.