Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Circuit Breaker HalfOpen State with Rate-Limited Probe - Implementation Complete

## Completed Steps

### Step 1: Enhance `circuit_breaker.rs` ✅
- [x] Added `probe_request()` - Primary entry-point for half-open probe admission with cooldown + quota check
- [x] Added `half_open_probe_success()` - Records successful probe, increments `HalfOpenWindow.completed`, delegates to `record_success()`
- [x] Added `half_open_probe_failure()` - Records failed probe, increments `HalfOpenWindow.failures`, delegates to `record_failure()`
- [x] Added `reset_half_open_window()` - Cleans up temporary window data after state transitions

### Step 2: Update `graceful_degradation.rs` ✅
- [x] Added `probe_oracle_with_circuit_breaker()` - Integrates circuit breaker probe with oracle health checks
- Flow: `probe_request()` → oracle health check → `half_open_probe_success()` / `half_open_probe_failure()`
- Returns `OracleHealth::Working` on success, `OracleHealth::Degraded` on failure, `OracleHealth::Broken` if probe rejected

### Step 3: Comprehensive Tests ✅
- [x] `test_probe_request_admitted_within_quota` - Rate-limited probe admitted when quota is available
- [x] `test_probe_request_rejected_when_quota_exhausted` - Probe rejects when quota exhausted, closes circuit (0 failures)
- [x] `test_probe_request_cooldown_enforcement` - Cooldown enforcement before probes are counted
- [x] `test_probe_request_not_half_open` - Returns false when breaker is not in HalfOpen (Closed/Open)
- [x] `test_half_open_probe_success_tracks_completion` - Tracks completed counter, auto-closes after threshold
- [x] `test_half_open_probe_failure_reopens_circuit` - Tracks failure counter, re-opens breaker immediately
- [x] `test_probe_request_quota_window_resets` - Quota window resets after evaluation_window_s passes
- [x] `test_quota_exhausted_with_failures_reopens` - Quota exhausted with failures re-opens circuit
- [x] `test_probe_oracle_with_circuit_breaker_integration` - Full integration with graceful degradation oracle probe

## Summary

The implementation enhances the circuit breaker's half-open state with:
1. **Rate-limited probe admission** via `probe_request()` integrating cooldown enforcement and quota-based scheduling
2. **Explicit probe tracking** via `half_open_probe_success()` and `half_open_probe_failure()` that update the `HalfOpenWindow` counters
3. **Graceful degradation integration** via `probe_oracle_with_circuit_breaker()` that orchestrates the full probe lifecycle
4. **Cleanup** via `reset_half_open_window()` for state transitions
144 changes: 143 additions & 1 deletion contracts/predictify-hybrid/src/circuit_breaker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ impl CircuitBreaker {
}
}

/// Check whether a read-only operation is allowed.
/// Check whether a read-only operation is allowed.
///
/// Read paths remain available while the breaker is paused so integrators can
/// inspect state, balances, and status without changing contract storage.
Expand Down Expand Up @@ -395,6 +395,148 @@ impl CircuitBreaker {
}
}

/// Explicitly request admission for a probe request in the half-open state.
///
/// This is the primary entry-point for callers that want to check whether
/// they are allowed to send a probe through the half-open breaker. It
/// combines the cooldown check, the quota-based admission window, and
/// rate-limit tracking into a single call.
///
/// # Returns
///
/// * `Ok(true)` – Probe is admitted (caller may proceed).
/// * `Ok(false)` – Probe is rejected (quota full, cooldown active, or
/// the breaker is not in HalfOpen).
/// * `Err(e)` – Storage error.
///
/// # Rate-limit integration
///
/// When the half-open quota is configured (`calls_per_minute > 0`), this
/// function records the admission in a temporary `HalfOpenWindow` and
/// returns `true` only if the window has remaining capacity. Once the
/// window is full the caller must wait for the next evaluation window
/// (or for the breaker to auto-close or re-open based on the probe
/// results accumulated in the window).
pub fn probe_request(env: &Env) -> Result<bool, Error> {
let state = Self::get_state(env)?;
if state.state != BreakerState::HalfOpen {
return Ok(false);
}

let config = Self::get_config(env)?;

// Enforce cooldown: probes are not counted (and not admitted) until
// `recovery_timeout` seconds have elapsed since entering HalfOpen.
let current_time = env.ledger().timestamp();
if current_time < state.half_open_since + config.recovery_timeout {
return Ok(false);
}

// Use quota-based admission when configured.
if config.half_open_quota.calls_per_minute > 0 {
Self::half_open_admit(env, &config)
} else {
// Fallback: simple max-requests gate.
if state.half_open_requests < config.half_open_max_requests {
Ok(true)
} else {
Ok(false)
}
}
}

/// Record a successful probe while in the half-open state.
///
/// This is a thin wrapper around `record_success` that additionally
/// increments the `HalfOpenWindow.completed` counter so the quota-based
/// scheduler can see how many probes succeeded.
///
/// After `half_open_max_requests` consecutive successes the breaker
/// auto-closes (see [`record_success`] for details).
pub fn half_open_probe_success(env: &Env) -> Result<(), Error> {
let state = Self::get_state(env)?;
if state.state != BreakerState::HalfOpen {
return Ok(());
}

// Track completion in the temporary window.
let key = CircuitBreakerTempData::HalfOpenWindow;
let current_time = env.ledger().timestamp();
let config = Self::get_config(env)?;
let mut window: HalfOpenWindow = env.storage().temporary().get(&key).unwrap_or(HalfOpenWindow {
admitted: 0,
completed: 0,
failures: 0,
window_start: current_time,
});
// Reset window if expired
if current_time >= window.window_start.saturating_add(config.half_open_quota.evaluation_window_s) {
window.admitted = 0;
window.completed = 0;
window.failures = 0;
window.window_start = current_time;
}
window.completed = window.completed.saturating_add(1);
env.storage().temporary().set(&key, &window);
env.storage().temporary().extend_ttl(&key, config.half_open_quota.evaluation_window_s as u32 + 86400, config.half_open_quota.evaluation_window_s as u32 + 86400);

// Delegate to record_success which handles the half-open → closed transition.
Self::record_success(env)
}

/// Record a failed probe while in the half-open state.
///
/// This is a thin wrapper around `record_failure` that additionally
/// increments the `HalfOpenWindow.failures` counter so the quota-based
/// scheduler can see how many probes failed.
///
/// A single failure re-opens the breaker (see [`record_failure`] for details).
pub fn half_open_probe_failure(env: &Env) -> Result<(), Error> {
let state = Self::get_state(env)?;
if state.state != BreakerState::HalfOpen {
return Ok(());
}

// Track completion in the temporary window.
let key = CircuitBreakerTempData::HalfOpenWindow;
let current_time = env.ledger().timestamp();
let config = Self::get_config(env)?;
let mut window: HalfOpenWindow = env.storage().temporary().get(&key).unwrap_or(HalfOpenWindow {
admitted: 0,
completed: 0,
failures: 0,
window_start: current_time,
});
// Reset window if expired
if current_time >= window.window_start.saturating_add(config.half_open_quota.evaluation_window_s) {
window.admitted = 0;
window.completed = 0;
window.failures = 0;
window.window_start = current_time;
}
window.failures = window.failures.saturating_add(1);
window.completed = window.completed.saturating_add(1);
env.storage().temporary().set(&key, &window);
env.storage().temporary().extend_ttl(&key, config.half_open_quota.evaluation_window_s as u32 + 86400, config.half_open_quota.evaluation_window_s as u32 + 86400);

// Delegate to record_failure which handles the half-open → open transition.
Self::record_failure(env)
}

/// Reset the half-open probe window counters.
///
/// This is useful after the breaker transitions out of HalfOpen so that
/// stale window data does not linger in temporary storage.
pub fn reset_half_open_window(env: &Env) {
let key = CircuitBreakerTempData::HalfOpenWindow;
env.storage().temporary().set(&key, &HalfOpenWindow {
admitted: 0,
completed: 0,
failures: 0,
window_start: 0,
});
}

/// Returns whether withdrawals are allowed under the current pause state.
pub fn are_withdrawals_allowed(env: &Env) -> Result<bool, Error> {
let state = Self::get_state(env)?;
Expand Down
Loading