|
| 1 | +# External Service Circuit Breaker (Issue #944) |
| 2 | + |
| 3 | +## Overview |
| 4 | +The External Service Circuit Breaker protects the ArenaX backend from cascading failures caused by outages, network timeouts, or degraded performance in third-party services (e.g. Stellar Horizon, Soroban RPC, external payment providers, webhooks). |
| 5 | + |
| 6 | +## States & State Transitions |
| 7 | + |
| 8 | +```mermaid |
| 9 | +stateDiagram-v2 |
| 10 | + [*] --> Closed |
| 11 | + Closed --> Open: Failures >= threshold |
| 12 | + Open --> HalfOpen: Cooldown period elapsed |
| 13 | + HalfOpen --> Closed: Consecutive successes >= success_threshold |
| 14 | + HalfOpen --> Open: Trial request fails (exponential backoff scaled) |
| 15 | +``` |
| 16 | + |
| 17 | +1. **Closed**: Normal state. All requests are allowed through. Consecutive failures are tracked. When failures reach `failure_threshold` (default: 5), the circuit trips to `Open`. |
| 18 | +2. **Open**: Outage state. Incoming calls fail fast immediately with `503 Service Unavailable` and a `Retry-After` header without making any network calls to the external service. |
| 19 | +3. **HalfOpen**: Recovery state. Once the exponential backoff cooldown has elapsed, the circuit admits a limited number of trial probe requests (`half_open_probe_limit`, default: 1). |
| 20 | + - If the trial request succeeds and reaches `success_threshold` (default: 2), the circuit transitions back to `Closed` and resets the backoff exponent. |
| 21 | + - If the trial request fails, the circuit immediately returns to `Open` and scales the cooldown duration exponentially. |
| 22 | + |
| 23 | +## Exponential Backoff Calculation |
| 24 | + |
| 25 | +The cooldown period before transitioning from `Open` to `HalfOpen` grows exponentially with consecutive trip cycles: |
| 26 | + |
| 27 | +$$\text{cooldown} = \min\left(\text{max\_backoff}, \; \text{initial\_backoff} \times \text{backoff\_multiplier}^{(\text{trip\_count} - 1)}\right)$$ |
| 28 | + |
| 29 | +### Default Settings |
| 30 | +- `failure_threshold`: 5 consecutive failures |
| 31 | +- `success_threshold`: 2 consecutive successes |
| 32 | +- `initial_backoff`: 5 seconds |
| 33 | +- `max_backoff`: 60 seconds |
| 34 | +- `backoff_multiplier`: 2.0 |
| 35 | +- `half_open_probe_limit`: 1 concurrent probe |
| 36 | +- `request_timeout`: 10 seconds |
| 37 | + |
| 38 | +## Usage |
| 39 | + |
| 40 | +### 1. Service Registry Wrapper |
| 41 | +```rust |
| 42 | +use arenax_backend::middleware::circuit_breaker::CircuitBreakerRegistry; |
| 43 | + |
| 44 | +let registry = CircuitBreakerRegistry::default(); |
| 45 | + |
| 46 | +// Execute outbound calls under the "stellar" circuit breaker |
| 47 | +let result = registry.call("stellar", || async { |
| 48 | + // Outbound HTTP request to Stellar Horizon |
| 49 | + reqwest::get("https://horizon.stellar.org/accounts/XYZ").await |
| 50 | +}).await; |
| 51 | + |
| 52 | +match result { |
| 53 | + Ok(data) => println!("Success: {:?}", data), |
| 54 | + Err(CircuitBreakerError::CircuitOpen { service, retry_after_secs }) => { |
| 55 | + eprintln!("Fast failed: {} circuit is open, retry in {}s", service, retry_after_secs); |
| 56 | + } |
| 57 | + Err(CircuitBreakerError::Timeout(dur)) => { |
| 58 | + eprintln!("Call timed out after {:?}", dur); |
| 59 | + } |
| 60 | + Err(CircuitBreakerError::Inner(e)) => { |
| 61 | + eprintln!("External call failed: {:?}", e); |
| 62 | + } |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +### 2. Actix-Web Route Middleware |
| 67 | +```rust |
| 68 | +use actix_web::{web, App}; |
| 69 | +use arenax_backend::middleware::circuit_breaker::{ |
| 70 | + CircuitBreaker, CircuitBreakerConfig, ExternalCircuitBreakerMiddleware, |
| 71 | +}; |
| 72 | + |
| 73 | +let breaker = Arc::new(CircuitBreaker::new( |
| 74 | + CircuitBreakerConfig::new("proxy_service") |
| 75 | + .with_failure_threshold(3) |
| 76 | + .with_initial_backoff(Duration::from_secs(10)), |
| 77 | +)); |
| 78 | + |
| 79 | +App::new() |
| 80 | + .service( |
| 81 | + web::scope("/api/external") |
| 82 | + .wrap(ExternalCircuitBreakerMiddleware::new(breaker.clone())) |
| 83 | + .route("/proxy", web::get().to(proxy_handler)) |
| 84 | + ); |
| 85 | +``` |
| 86 | + |
| 87 | +## Metrics Export |
| 88 | +The circuit breaker publishes Prometheus metrics via the `/metrics` endpoint: |
| 89 | + |
| 90 | +| Metric Name | Type | Labels | Description | |
| 91 | +|-------------|------|--------|-------------| |
| 92 | +| `circuit_breaker_state` | Gauge | `service` | Current state (0 = Closed, 1 = HalfOpen, 2 = Open) | |
| 93 | +| `circuit_breaker_requests_total` | Counter | `service`, `status` | Total requests (`success`, `failure`, `rejected`) | |
| 94 | +| `circuit_breaker_trips_total` | Counter | `service` | Total times circuit tripped Open | |
0 commit comments