summaryrefslogtreecommitdiff
path: root/internal/llm/circuitbreaker_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/llm/circuitbreaker_test.go')
-rw-r--r--internal/llm/circuitbreaker_test.go103
1 files changed, 103 insertions, 0 deletions
diff --git a/internal/llm/circuitbreaker_test.go b/internal/llm/circuitbreaker_test.go
new file mode 100644
index 0000000..17510b3
--- /dev/null
+++ b/internal/llm/circuitbreaker_test.go
@@ -0,0 +1,103 @@
+package llm
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCircuitBreaker_NilIsNoOp(t *testing.T) {
+ var cb *circuitBreaker
+ if !cb.Allow() {
+ t.Fatal("nil breaker must allow")
+ }
+ // These must not panic on a nil receiver.
+ cb.recordFailure()
+ cb.recordSuccess()
+}
+
+func TestCircuitBreaker_TripsAfterThreshold(t *testing.T) {
+ cb := newCircuitBreaker(3, time.Minute)
+ for i := 0; i < 2; i++ {
+ cb.recordFailure()
+ if !cb.Allow() {
+ t.Fatalf("breaker should stay closed before threshold (i=%d)", i)
+ }
+ }
+ cb.recordFailure() // third failure trips it
+ if cb.Allow() {
+ t.Fatal("breaker should be open after threshold failures")
+ }
+}
+
+func TestCircuitBreaker_SuccessResets(t *testing.T) {
+ cb := newCircuitBreaker(2, time.Minute)
+ cb.recordFailure()
+ cb.recordSuccess()
+ cb.recordFailure() // only one failure since reset; should not trip
+ if !cb.Allow() {
+ t.Fatal("breaker should remain closed after success reset")
+ }
+}
+
+func TestCircuitBreaker_HalfOpenAllowsSingleProbe(t *testing.T) {
+ now := time.Unix(0, 0)
+ cb := newCircuitBreaker(1, 10*time.Second)
+ cb.now = func() time.Time { return now }
+
+ cb.recordFailure() // trips (threshold 1)
+ if cb.Allow() {
+ t.Fatal("should be open immediately after trip")
+ }
+
+ // Advance past cooldown: first Allow transitions to half-open and permits one probe.
+ now = now.Add(11 * time.Second)
+ if !cb.Allow() {
+ t.Fatal("expected half-open probe to be allowed")
+ }
+ // A second concurrent probe is rejected.
+ if cb.Allow() {
+ t.Fatal("second probe must be rejected while half-open")
+ }
+}
+
+func TestCircuitBreaker_HalfOpenProbeSuccessCloses(t *testing.T) {
+ now := time.Unix(0, 0)
+ cb := newCircuitBreaker(1, time.Second)
+ cb.now = func() time.Time { return now }
+
+ cb.recordFailure()
+ now = now.Add(2 * time.Second)
+ if !cb.Allow() {
+ t.Fatal("expected probe allowed")
+ }
+ cb.recordSuccess()
+ if !cb.Allow() {
+ t.Fatal("breaker should be closed after successful probe")
+ }
+}
+
+func TestCircuitBreaker_HalfOpenProbeFailureReopens(t *testing.T) {
+ now := time.Unix(0, 0)
+ cb := newCircuitBreaker(1, time.Second)
+ cb.now = func() time.Time { return now }
+
+ cb.recordFailure()
+ now = now.Add(2 * time.Second)
+ if !cb.Allow() {
+ t.Fatal("expected probe allowed")
+ }
+ cb.recordFailure() // probe failed: reopen
+ if cb.Allow() {
+ t.Fatal("breaker should reopen after failed probe")
+ }
+}
+
+func TestCircuitBreaker_ZeroThresholdNeverTrips(t *testing.T) {
+ cb := newCircuitBreaker(0, time.Minute)
+ for i := 0; i < 100; i++ {
+ cb.recordFailure()
+ }
+ if !cb.Allow() {
+ t.Fatal("threshold<=0 should never trip the breaker")
+ }
+}