Skip to content

Commit ae87ac9

Browse files
authored
Extract circuit breaker pattern with comprehensive tests (task 1) (#321)
* test: add comprehensive circuit breaker tests (task 1) Add benchmark tests, example tests, and edge case tests to the shared circuit breaker package to ensure comprehensive test coverage. New tests: - BenchmarkCircuitBreaker_Execute: measures wrapper overhead - BenchmarkCircuitBreaker_Execute_Parallel: concurrent load testing - BenchmarkCircuitBreaker_StateCheck: state check overhead - BenchmarkCircuitBreaker_OpenState: fast-fail performance - TestCircuitBreaker_ConcurrentOpenCircuit: concurrent open state handling - TestCircuitBreaker_MaxRequestsInHalfOpen: half-open limiting - TestCircuitBreaker_ReadyToTripFailureRatio: ratio-based tripping Example tests for godoc: - ExampleNewCircuitBreaker - ExampleCircuitBreaker_Execute - ExampleDefaultCircuitBreakerConfig - ExampleCircuitBreaker_Execute_withFallback - ExampleCircuitBreaker_Execute_customConfig - ExampleCircuitBreaker_State - ExampleCircuitBreaker_Execute_errorHandling Coverage: 100% for circuitbreaker.go * test: improve circuit breaker test robustness and coverage Improvements based on code review feedback: 1. Replace fixed sleep with polling loop in TestCircuitBreaker_MaxRequestsInHalfOpen to avoid flakiness under CI load 2. Refactor TestCircuitBreaker_ReadyToTripFailureRatio to use subtests for clearer failure diagnosis 3. Add TestCircuitBreaker_ContextCancellationInHalfOpen to verify context cancellation behavior during half-open state --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 262c1fe commit ae87ac9

2 files changed

Lines changed: 491 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package clients_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
"time"
10+
11+
"github.com/meridianhub/meridian/shared/pkg/clients"
12+
"github.com/sony/gobreaker/v2"
13+
)
14+
15+
var (
16+
errServiceUnavailable = errors.New("service unavailable")
17+
errServiceError = errors.New("service error")
18+
)
19+
20+
// ExampleNewCircuitBreaker demonstrates creating a circuit breaker with default configuration.
21+
func ExampleNewCircuitBreaker() {
22+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
23+
config := clients.DefaultCircuitBreakerConfig("my-service")
24+
cb := clients.NewCircuitBreaker(config, logger)
25+
26+
fmt.Printf("Circuit breaker created, state: %s\n", cb.State())
27+
// Output: Circuit breaker created, state: closed
28+
}
29+
30+
// ExampleCircuitBreaker_Execute demonstrates basic circuit breaker usage.
31+
func ExampleCircuitBreaker_Execute() {
32+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
33+
config := clients.DefaultCircuitBreakerConfig("example-service")
34+
cb := clients.NewCircuitBreaker(config, logger)
35+
36+
ctx := context.Background()
37+
38+
// Execute operation with circuit breaker protection
39+
result, err := cb.Execute(ctx, func() (any, error) {
40+
// Your downstream service call here
41+
return "success", nil
42+
})
43+
if err != nil {
44+
fmt.Printf("Error: %v\n", err)
45+
return
46+
}
47+
48+
fmt.Printf("Result: %v\n", result)
49+
// Output: Result: success
50+
}
51+
52+
// ExampleDefaultCircuitBreakerConfig demonstrates the default configuration values.
53+
func ExampleDefaultCircuitBreakerConfig() {
54+
config := clients.DefaultCircuitBreakerConfig("my-service")
55+
56+
fmt.Printf("Name: %s\n", config.Name)
57+
fmt.Printf("MaxRequests: %d\n", config.MaxRequests)
58+
fmt.Printf("Interval: %v\n", config.Interval)
59+
fmt.Printf("Timeout: %v\n", config.Timeout)
60+
// Output:
61+
// Name: my-service
62+
// MaxRequests: 1
63+
// Interval: 1m0s
64+
// Timeout: 30s
65+
}
66+
67+
// ExampleCircuitBreaker_Execute_withFallback demonstrates fallback logic when circuit is open.
68+
func ExampleCircuitBreaker_Execute_withFallback() {
69+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
70+
71+
// Configure circuit to trip after 2 failures with short timeout
72+
config := clients.CircuitBreakerConfig{
73+
Name: "fallback-service",
74+
MaxRequests: 1,
75+
Interval: 60 * time.Second,
76+
Timeout: 30 * time.Second,
77+
ReadyToTrip: func(counts gobreaker.Counts) bool {
78+
return counts.ConsecutiveFailures >= 2
79+
},
80+
}
81+
cb := clients.NewCircuitBreaker(config, logger)
82+
ctx := context.Background()
83+
84+
// Simulate service call with fallback
85+
executeWithFallback := func() (string, error) {
86+
result, err := cb.Execute(ctx, func() (any, error) {
87+
return nil, errServiceUnavailable
88+
})
89+
if err != nil {
90+
// Check if circuit is open
91+
if errors.Is(err, gobreaker.ErrOpenState) {
92+
return "cached-data", nil
93+
}
94+
return "", err
95+
}
96+
return result.(string), nil
97+
}
98+
99+
// First two calls fail and trip the circuit
100+
_, _ = executeWithFallback()
101+
_, _ = executeWithFallback()
102+
103+
// Third call returns cached data because circuit is open
104+
result, _ := executeWithFallback()
105+
fmt.Printf("Result: %s\n", result)
106+
// Output: Result: cached-data
107+
}
108+
109+
// ExampleCircuitBreaker_Execute_customConfig demonstrates custom circuit breaker configuration.
110+
func ExampleCircuitBreaker_Execute_customConfig() {
111+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
112+
113+
// Custom configuration with aggressive circuit breaking
114+
config := clients.CircuitBreakerConfig{
115+
Name: "aggressive-service",
116+
MaxRequests: 3, // Allow 3 requests in half-open state
117+
Interval: 120 * time.Second, // Reset counts every 2 minutes
118+
Timeout: 45 * time.Second, // Stay open for 45 seconds
119+
ReadyToTrip: func(counts gobreaker.Counts) bool {
120+
// Trip after 3 consecutive failures
121+
return counts.ConsecutiveFailures >= 3
122+
},
123+
OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
124+
// Log state changes for monitoring
125+
fmt.Printf("Circuit %s: %s -> %s\n", name, from, to)
126+
},
127+
}
128+
129+
cb := clients.NewCircuitBreaker(config, logger)
130+
ctx := context.Background()
131+
132+
// Execute operation
133+
_, err := cb.Execute(ctx, func() (any, error) {
134+
return "success", nil
135+
})
136+
if err != nil {
137+
fmt.Printf("Error: %v\n", err)
138+
return
139+
}
140+
141+
fmt.Printf("State after success: %s\n", cb.State())
142+
// Output: State after success: closed
143+
}
144+
145+
// ExampleCircuitBreaker_State demonstrates monitoring circuit breaker state.
146+
func ExampleCircuitBreaker_State() {
147+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
148+
config := clients.DefaultCircuitBreakerConfig("monitored-service")
149+
cb := clients.NewCircuitBreaker(config, logger)
150+
151+
// Check current state
152+
state := cb.State()
153+
154+
switch state {
155+
case gobreaker.StateClosed:
156+
fmt.Println("Circuit is closed - normal operation")
157+
case gobreaker.StateOpen:
158+
fmt.Println("Circuit is open - service is unhealthy")
159+
case gobreaker.StateHalfOpen:
160+
fmt.Println("Circuit is half-open - testing recovery")
161+
}
162+
// Output: Circuit is closed - normal operation
163+
}
164+
165+
// ExampleCircuitBreaker_Execute_errorHandling demonstrates comprehensive error handling.
166+
func ExampleCircuitBreaker_Execute_errorHandling() {
167+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
168+
config := clients.DefaultCircuitBreakerConfig("error-handling-service")
169+
cb := clients.NewCircuitBreaker(config, logger)
170+
171+
ctx := context.Background()
172+
173+
// Execute with comprehensive error handling
174+
_, err := cb.Execute(ctx, func() (any, error) {
175+
return nil, errServiceError
176+
})
177+
if err != nil {
178+
switch {
179+
case errors.Is(err, gobreaker.ErrOpenState):
180+
fmt.Println("Service unavailable - using fallback")
181+
case errors.Is(err, gobreaker.ErrTooManyRequests):
182+
fmt.Println("Service busy - retry later")
183+
case errors.Is(err, context.DeadlineExceeded):
184+
fmt.Println("Request timeout")
185+
default:
186+
fmt.Printf("Service error: %v\n", err)
187+
}
188+
}
189+
// Output: Service error: service error
190+
}

0 commit comments

Comments
 (0)