Files
openserp/core/circuit_breaker_test.go

174 lines
5.5 KiB
Go

package core
import (
"context"
"testing"
"time"
)
func newTestCircuitBreaker(t *testing.T, cfg CircuitBreakerConfig) *CircuitBreaker {
t.Helper()
return NewCircuitBreaker("test-engine", cfg)
}
// TestCircuitBreaker_OpensAfterThreshold verifies that consecutive failures in closed state
// move the breaker to open exactly on configured threshold and block new requests.
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 3,
RecoveryTimeout: time.Second,
SuccessThreshold: 1,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure(context.Background())
cb.RecordFailure(context.Background())
if cb.State() != CircuitClosed {
t.Fatalf("expected closed after 2 failures, got: %s", cb.State())
}
cb.RecordFailure(context.Background())
if cb.State() != CircuitOpen {
t.Fatalf("expected open after %d failures, got: %s", cfg.FailureThreshold, cb.State())
}
if cb.AllowRequest(context.Background()) {
t.Error("expected request blocked in open state")
}
}
// TestCircuitBreaker_RecoveryToHalfOpen verifies timed recovery from open to half-open
// when recovery timeout elapses and a new request is attempted.
func TestCircuitBreaker_RecoveryToHalfOpen(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 2,
RecoveryTimeout: 50 * time.Millisecond,
SuccessThreshold: 1,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure(context.Background())
cb.RecordFailure(context.Background())
if cb.State() != CircuitOpen {
t.Fatal("expected open")
}
time.Sleep(60 * time.Millisecond)
if !cb.AllowRequest(context.Background()) {
t.Error("should allow request after recovery timeout")
}
if cb.State() != CircuitHalfOpen {
t.Errorf("expected half-open, got: %s", cb.State())
}
}
// TestCircuitBreaker_HalfOpenSuccessClosesCircuit verifies that half-open state closes
// only after configured number of successful probes.
func TestCircuitBreaker_HalfOpenSuccessClosesCircuit(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 1,
RecoveryTimeout: 20 * time.Millisecond,
SuccessThreshold: 2,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure(context.Background())
if cb.State() != CircuitOpen {
t.Fatalf("expected open, got: %s", cb.State())
}
time.Sleep(30 * time.Millisecond)
if !cb.AllowRequest(context.Background()) {
t.Fatal("expected request to pass in recovery window")
}
if cb.State() != CircuitHalfOpen {
t.Fatalf("expected half-open after recovery timeout, got: %s", cb.State())
}
cb.RecordSuccess(context.Background())
if cb.State() != CircuitHalfOpen {
t.Fatalf("expected to stay half-open until success threshold reached, got: %s", cb.State())
}
cb.RecordSuccess(context.Background())
if cb.State() != CircuitClosed {
t.Fatalf("expected closed after success threshold reached, got: %s", cb.State())
}
}
// TestCircuitBreaker_HalfOpenFailureReopens verifies that a failed probe in half-open
// immediately re-opens the circuit.
func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 1,
RecoveryTimeout: 20 * time.Millisecond,
SuccessThreshold: 1,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure(context.Background())
time.Sleep(30 * time.Millisecond)
if !cb.AllowRequest(context.Background()) {
t.Fatal("expected probe request in half-open")
}
if cb.State() != CircuitHalfOpen {
t.Fatalf("expected half-open, got: %s", cb.State())
}
cb.RecordFailure(context.Background())
if cb.State() != CircuitOpen {
t.Fatalf("expected open after failed half-open probe, got: %s", cb.State())
}
}
// TestCircuitBreaker_Stats verifies stats payload fields and that retry_in is exposed
// only when breaker is open.
func TestCircuitBreaker_Stats(t *testing.T) {
cb := NewCircuitBreaker("test-engine", DefaultCircuitBreakerConfig())
cb.RecordFailure(context.Background())
stats := cb.Stats()
if stats["engine"] != "test-engine" {
t.Fatalf("expected engine=test-engine, got: %v", stats["engine"])
}
if stats["state"] != "closed" {
t.Fatalf("expected state=closed, got: %v", stats["state"])
}
if stats["failure_count"].(int) != 1 {
t.Fatalf("expected failure_count=1, got: %v", stats["failure_count"])
}
if _, ok := stats["retry_in"]; ok {
t.Fatalf("did not expect retry_in in closed state, got: %v", stats["retry_in"])
}
latencyCB := NewCircuitBreaker("latency-engine", DefaultCircuitBreakerConfig())
latencyCB.RecordSuccessDuration(context.Background(), 25*time.Millisecond)
latencyStats := latencyCB.Stats()
avg, ok := latencyStats["avg_response_ms"].(int64)
if !ok || avg <= 0 {
t.Fatalf("expected avg_response_ms int64 > 0, got: %v (%T)", latencyStats["avg_response_ms"], latencyStats["avg_response_ms"])
}
openCfg := CircuitBreakerConfig{FailureThreshold: 1, RecoveryTimeout: time.Second, SuccessThreshold: 1}
openCB := NewCircuitBreaker("open-engine", openCfg)
openCB.RecordFailure(context.Background())
openStats := openCB.Stats()
retryIn, ok := openStats["retry_in"].(int64)
if !ok {
t.Fatalf("expected retry_in int64 in open state, got: %T", openStats["retry_in"])
}
if retryIn <= 0 {
t.Fatalf("expected retry_in > 0 in open state, got: %d", retryIn)
}
}
// TestCircuitBreakerManager_AllStats verifies manager creates and reports per-engine breakers.
func TestCircuitBreakerManager_AllStats(t *testing.T) {
mgr := NewCircuitBreakerManager(DefaultCircuitBreakerConfig())
mgr.Get("google")
mgr.Get("yandex")
stats := mgr.AllStats()
if len(stats) != 2 {
t.Errorf("expected 2 entries, got: %d", len(stats))
}
}