Implementing Nginx Retry Logic: Proxy Fallback from Upstream A to B and Back to A


2 views


When configuring Nginx as a reverse proxy with multiple backend servers, a common requirement is implementing intelligent retry logic. The challenge arises when you need to:

  • First attempt connection to upstream server A
  • Fall back to server B if A fails
  • Retry server A again after B's response

The standard Nginx proxy_next_upstream directive typically handles simple failover scenarios, but doesn't provide the precise control needed for complex retry patterns. The key limitations include:

  • No native support for retrying previously failed upstreams
  • Limited conditional logic based on specific status codes
  • Difficulty coordinating with external control processes

Here's an improved configuration that properly handles the retry flow:

upstream backend_a {
    server 192.168.1.10:8080;
}

upstream backend_b {
    server 192.168.1.20:8080;
}

server {
    listen 80;
    server_name example.com;

    location / {
        error_page 502 = @try_backend_b;
        proxy_pass http://backend_a;
        proxy_intercept_errors on;
    }

    location @try_backend_b {
        error_page 502 = @retry_backend_a;
        proxy_pass http://backend_b;
        proxy_intercept_errors on;
        
        # Optional: Add logic to contact control server
        error_page 418 = @contact_control_server;
    }

    location @retry_backend_a {
        proxy_pass http://backend_a;
    }

    location @contact_control_server {
        proxy_pass http://127.0.0.1:82;
        proxy_intercept_errors on;
        
        # Control server response handling
        error_page 200 = @retry_backend_a;
        error_page 503 = @final_error;
    }

    location @final_error {
        return 503 "Service Unavailable";
    }
}

The solution works through several important mechanisms:

  1. Named locations create distinct processing blocks for each retry attempt
  2. error_page directives with equals sign (=) preserve the original request method
  3. proxy_intercept_errors enables custom handling of backend responses

For more sophisticated scenarios, consider integrating health checks:

upstream backend_group {
    zone backend_zone 64k;
    server 192.168.1.10:8080 max_fails=1 fail_timeout=10s;
    server 192.168.1.20:8080 max_fails=1 fail_timeout=10s;
    
    # Custom health check
    health_check uri=/health interval=5s;
}

When integrating with your backend control system:

location @control_trigger {
    proxy_pass http://control-server/start_backend;
    proxy_set_header X-Original-URI $request_uri;
    
    # Important for POST requests
    proxy_method POST;
    proxy_pass_request_body on;
    
    # Response handling
    proxy_intercept_errors on;
    error_page 200 = @retry_original;
    error_page 503 = @service_unavailable;
}
  • Set appropriate timeouts (proxy_connect_timeout, proxy_read_timeout)
  • Limit retry attempts to prevent request loops
  • Consider implementing caching for control server responses

When configuring Nginx as a reverse proxy with multiple backend servers, implementing a robust fallback mechanism where Nginx sequentially attempts upstream servers (A → B → A) presents unique challenges. The standard proxy_pass directive doesn't natively support this multi-stage retry behavior.

The conventional approach using error_page directives has limitations:

location @backend {
    proxy_pass http://backend_a;
    error_page 502 @try_backend_b;
}

location @try_backend_b {
    proxy_pass http://backend_b;
    error_page 502 @retry_backend_a;
}

This fails because:

  • Nginx doesn't properly chain named locations for retries
  • Status codes from intermediate handlers get passed to clients
  • There's no clean way to implement conditional retry logic

The most reliable approach combines upstream blocks with proxy_next_upstream:

upstream backend_chain {
    server backend_a:80 max_fails=0;
    server backend_b:80 max_fails=0 backup;
    server backend_a:80 backup;
}

server {
    listen 80;
    
    location / {
        proxy_pass http://backend_chain;
        proxy_next_upstream error timeout http_502;
        proxy_intercept_errors on;
        
        # Control server integration
        error_page 502 = @start_backend;
    }

    location @start_backend {
        proxy_pass http://control_server:82;
        proxy_intercept_errors on;
        
        # Successful startup (451 is just an example)
        error_page 451 = @backend_chain;
        
        # Control server failure
        error_page 502 503 /fatal_error.html;
    }
}

For more complex scenarios requiring conditional retries:

map $upstream_status $retry_strategy {
    502     @start_backend;
    default $upstream_response_status;
}

server {
    # ... other config ...
    
    location / {
        proxy_pass http://backend_chain;
        proxy_next_upstream error timeout http_502;
        
        # Dynamic retry handling
        error_page 502 = $retry_strategy;
    }
}

Key implementation details:

  • Set appropriate timeouts (proxy_connect_timeout, proxy_read_timeout)
  • Consider health checks for backend servers
  • Implement circuit breaking patterns to avoid cascading failures
  • Log all retry attempts for debugging

For maximum flexibility with OpenResty:

location / {
    access_by_lua_block {
        local attempts = 0
        local backends = {"http://backend_a", "http://backend_b"}
        
        while attempts < 3 do
            local res = ngx.location.capture("/proxy", {
                args = { backend = backends[attempts % 2 + 1] }
            })
            
            if res.status < 500 then
                ngx.exit(res.status)
            end
            
            attempts = attempts + 1
        end
        
        ngx.exit(502)
    }
}

location /proxy {
    internal;
    proxy_pass $arg_backend;
}