rdev/internal/circuitbreaker/circuitbreaker_test.go
jordan a9ad3d8304
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
chore: accumulated platform hardening and CI fixes
CI / Woodpecker:
- Add explicit depends_on to all .woodpecker.yml steps (rdev + templates)
- Fix skip_tls_verify -> skip-tls-verify (correct Kaniko flag name)
- Add replicasets get/list to deployer RBAC for rollout status
- Skeleton template: add failure:ignore on docs steps, Traefik TLS
  annotations on ingress, depends_on on verify step

Component templates:
- Fix container name in deploy steps (PROJECT_NAME-COMPONENT_NAME)
- Replace kubectl scale with kubectl patch for replicas
- Add post-deploy image verification and rollout status checks
- Applied consistently across all 5 component templates

Adapters:
- gitea: Add HTTP client timeout (30s), context cancellation checks,
  handle 404 on GetRepo/DeleteRepo
- zot: Add retry with exponential backoff (doWithRetry), limit response
  body reads to 10MB
- cockroach: Use net.JoinHostPort for IPv6-safe DSN construction
- woodpecker: Fix error wrapping (%v -> %w)
- redis: Fix error wrapping (%v -> %w)
- deployer: Add context cancellation checks

Services:
- apikey_service: Fix error wrapping (%v -> %w)
- component_deploy: Fix error wrapping (%v -> %w)
- project_infra: Fix error wrapping (%v -> %w)
- webhook/dispatcher: Fix error wrapping (%v -> %w)

Other:
- CLAUDE.md: Add guide links for Gitea, Go 1.25, Woodpecker v3,
  Traefik v3, Zot registry
- circuitbreaker: Add test for error wrapping
- docs: Update deployment, troubleshooting, and runbook docs
- health: Fix error wrapping (%v -> %w)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:16:56 -07:00

289 lines
6.2 KiB
Go

// TODO: Migrate time.Sleep-based tests to testing/synctest (Go 1.25) for
// deterministic, instant execution. Priority: TestCircuitBreakerTimerReset,
// TestCircuitBreakerRecovery. Also applies to ratelimit_test.go,
// api_client_test.go, dispatcher_test.go, cached/project_repository_test.go.
package circuitbreaker
import (
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
var errTest = errors.New("test error")
func TestCircuitBreaker_Closed(t *testing.T) {
cb := New(DefaultConfig())
// Should be closed initially
if cb.State() != Closed {
t.Errorf("initial state = %v, want Closed", cb.State())
}
// Successful calls should work
called := false
err := cb.Execute(func() error {
called = true
return nil
})
if err != nil {
t.Errorf("Execute() error = %v", err)
}
if !called {
t.Error("function was not called")
}
}
func TestCircuitBreaker_OpensAfterFailures(t *testing.T) {
cb := New(Config{
FailureThreshold: 3,
ResetTimeout: 1 * time.Second,
})
// Fail 3 times
for i := 0; i < 3; i++ {
_ = cb.Execute(func() error {
return errTest
})
}
// Should be open now
if cb.State() != Open {
t.Errorf("state after 3 failures = %v, want Open", cb.State())
}
// Next call should fail immediately
called := false
err := cb.Execute(func() error {
called = true
return nil
})
if err != ErrCircuitOpen {
t.Errorf("Execute() error = %v, want ErrCircuitOpen", err)
}
if called {
t.Error("function should not be called when circuit is open")
}
}
func TestCircuitBreaker_HalfOpenAfterTimeout(t *testing.T) {
cb := New(Config{
FailureThreshold: 2,
ResetTimeout: 50 * time.Millisecond,
})
// Trip the circuit
_ = cb.Execute(func() error { return errTest })
_ = cb.Execute(func() error { return errTest })
if cb.State() != Open {
t.Fatalf("expected Open state, got %v", cb.State())
}
// Wait for reset timeout
time.Sleep(60 * time.Millisecond)
// Next request should be allowed (half-open)
called := false
err := cb.Execute(func() error {
called = true
return nil
})
if err != nil {
t.Errorf("Execute() in half-open = %v", err)
}
if !called {
t.Error("function should be called in half-open state")
}
// After success, circuit should be closed
if cb.State() != Closed {
t.Errorf("state after successful probe = %v, want Closed", cb.State())
}
}
func TestCircuitBreaker_HalfOpenRetripsOnFailure(t *testing.T) {
cb := New(Config{
FailureThreshold: 2,
ResetTimeout: 50 * time.Millisecond,
})
// Trip the circuit
_ = cb.Execute(func() error { return errTest })
_ = cb.Execute(func() error { return errTest })
// Wait for reset timeout
time.Sleep(60 * time.Millisecond)
// Fail in half-open state
_ = cb.Execute(func() error { return errTest })
// Should be open again
if cb.State() != Open {
t.Errorf("state after half-open failure = %v, want Open", cb.State())
}
}
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
cb := New(Config{
FailureThreshold: 3,
ResetTimeout: 1 * time.Second,
})
// 2 failures
cb.Execute(func() error { return errTest })
cb.Execute(func() error { return errTest })
// 1 success should reset the count
cb.Execute(func() error { return nil })
// 2 more failures - should not open (only 2 consecutive)
cb.Execute(func() error { return errTest })
cb.Execute(func() error { return errTest })
if cb.State() != Closed {
t.Errorf("state = %v, want Closed (success reset counter)", cb.State())
}
}
func TestCircuitBreaker_Stats(t *testing.T) {
cb := New(Config{
FailureThreshold: 5,
ResetTimeout: 1 * time.Second,
})
// Some operations
cb.Execute(func() error { return nil })
cb.Execute(func() error { return errTest })
cb.Execute(func() error { return errTest })
stats := cb.Stats()
if stats.State != Closed {
t.Errorf("Stats.State = %v, want Closed", stats.State)
}
if stats.Failures != 2 {
t.Errorf("Stats.Failures = %d, want 2", stats.Failures)
}
if stats.LastFailure.IsZero() {
t.Error("Stats.LastFailure should not be zero")
}
}
func TestCircuitBreaker_Reset(t *testing.T) {
cb := New(Config{
FailureThreshold: 2,
ResetTimeout: 1 * time.Hour,
})
// Trip the circuit
cb.Execute(func() error { return errTest })
cb.Execute(func() error { return errTest })
if cb.State() != Open {
t.Fatalf("expected Open state, got %v", cb.State())
}
// Manual reset
cb.Reset()
if cb.State() != Closed {
t.Errorf("state after Reset() = %v, want Closed", cb.State())
}
// Should work again
called := false
cb.Execute(func() error {
called = true
return nil
})
if !called {
t.Error("function should be called after Reset()")
}
}
func TestCircuitBreaker_Concurrent(t *testing.T) {
cb := New(Config{
FailureThreshold: 10,
ResetTimeout: 100 * time.Millisecond,
})
var wg sync.WaitGroup
var successCount, failCount atomic.Int64
// Concurrent executions
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
var err error
if id%3 == 0 {
err = errTest
}
result := cb.Execute(func() error { return err })
if result == nil {
successCount.Add(1)
} else {
failCount.Add(1)
}
}(i)
}
wg.Wait()
total := successCount.Load() + failCount.Load()
if total != 100 {
t.Errorf("total executions = %d, want 100", total)
}
}
func TestState_String(t *testing.T) {
tests := []struct {
state State
want string
}{
{Closed, "closed"},
{Open, "open"},
{HalfOpen, "half-open"},
{State(99), "unknown"},
}
for _, tt := range tests {
if got := tt.state.String(); got != tt.want {
t.Errorf("State(%d).String() = %q, want %q", tt.state, got, tt.want)
}
}
}
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.FailureThreshold != 5 {
t.Errorf("FailureThreshold = %d, want 5", cfg.FailureThreshold)
}
if cfg.ResetTimeout != 30*time.Second {
t.Errorf("ResetTimeout = %v, want 30s", cfg.ResetTimeout)
}
if cfg.HalfOpenRequests != 1 {
t.Errorf("HalfOpenRequests = %d, want 1", cfg.HalfOpenRequests)
}
}
func TestNew_DefaultsInvalidValues(t *testing.T) {
cb := New(Config{
FailureThreshold: -1,
ResetTimeout: -1,
HalfOpenRequests: -1,
})
stats := cb.Stats()
if stats.State != Closed {
t.Error("new circuit breaker should be Closed")
}
}