390 lines
9.7 KiB
Bash
Executable File
390 lines
9.7 KiB
Bash
Executable File
#!/bin/bash
|
|
# Test: Circuit Breaker Functionality
|
|
# Tests circuit breaker states, transitions, and recovery
|
|
|
|
set -uo pipefail
|
|
# Note: Not using -e to allow collecting all test results
|
|
|
|
TEST_DIR=$(mktemp -d)
|
|
PASSED=0
|
|
FAILED=0
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
NC='\033[0m'
|
|
|
|
log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASSED++)); }
|
|
log_fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAILED++)); }
|
|
log_test() { echo -e "${YELLOW}[TEST]${NC} $1"; }
|
|
|
|
cleanup() {
|
|
rm -rf "$TEST_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
cd "$TEST_DIR"
|
|
|
|
echo "========================================"
|
|
echo "Loki Mode Circuit Breaker Tests"
|
|
echo "========================================"
|
|
echo ""
|
|
|
|
# Initialize structure
|
|
mkdir -p .loki/{state,config}
|
|
|
|
# Create circuit breaker config
|
|
cat > .loki/config/circuit-breakers.yaml << 'EOF'
|
|
defaults:
|
|
failureThreshold: 5
|
|
cooldownSeconds: 300
|
|
halfOpenRequests: 3
|
|
|
|
overrides:
|
|
external-api:
|
|
failureThreshold: 3
|
|
cooldownSeconds: 600
|
|
eng-frontend:
|
|
failureThreshold: 10
|
|
cooldownSeconds: 180
|
|
EOF
|
|
|
|
# Initialize orchestrator state
|
|
cat > .loki/state/orchestrator.json << 'EOF'
|
|
{
|
|
"circuitBreakers": {}
|
|
}
|
|
EOF
|
|
|
|
# Test 1: Initialize circuit breaker (CLOSED state)
|
|
log_test "Initialize circuit breaker in CLOSED state"
|
|
python3 << 'EOF'
|
|
import json
|
|
from datetime import datetime
|
|
|
|
with open('.loki/state/orchestrator.json', 'r') as f:
|
|
state = json.load(f)
|
|
|
|
# Initialize circuit breaker for eng-backend
|
|
state['circuitBreakers']['eng-backend'] = {
|
|
'state': 'closed',
|
|
'failures': 0,
|
|
'lastFailure': None,
|
|
'cooldownUntil': None,
|
|
'halfOpenAttempts': 0
|
|
}
|
|
|
|
with open('.loki/state/orchestrator.json', 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
|
|
print("INITIALIZED")
|
|
EOF
|
|
|
|
cb_state=$(python3 -c "
|
|
import json
|
|
data = json.load(open('.loki/state/orchestrator.json'))
|
|
print(data['circuitBreakers']['eng-backend']['state'])
|
|
")
|
|
|
|
if [ "$cb_state" = "closed" ]; then
|
|
log_pass "Circuit breaker initialized in CLOSED state"
|
|
else
|
|
log_fail "Expected CLOSED, got $cb_state"
|
|
fi
|
|
|
|
# Test 2: Record failures
|
|
log_test "Record failures incrementally"
|
|
python3 << 'EOF'
|
|
import json
|
|
from datetime import datetime
|
|
|
|
with open('.loki/state/orchestrator.json', 'r') as f:
|
|
state = json.load(f)
|
|
|
|
cb = state['circuitBreakers']['eng-backend']
|
|
|
|
# Record 3 failures
|
|
for i in range(3):
|
|
cb['failures'] += 1
|
|
cb['lastFailure'] = datetime.utcnow().isoformat() + 'Z'
|
|
|
|
with open('.loki/state/orchestrator.json', 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
|
|
print(f"FAILURES:{cb['failures']}")
|
|
EOF
|
|
|
|
failures=$(python3 -c "
|
|
import json
|
|
data = json.load(open('.loki/state/orchestrator.json'))
|
|
print(data['circuitBreakers']['eng-backend']['failures'])
|
|
")
|
|
|
|
if [ "$failures" -eq 3 ]; then
|
|
log_pass "Recorded 3 failures"
|
|
else
|
|
log_fail "Expected 3 failures, got $failures"
|
|
fi
|
|
|
|
# Test 3: Trip circuit breaker (CLOSED -> OPEN)
|
|
log_test "Trip circuit breaker after threshold"
|
|
python3 << 'EOF'
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
FAILURE_THRESHOLD = 5
|
|
COOLDOWN_SECONDS = 300
|
|
|
|
with open('.loki/state/orchestrator.json', 'r') as f:
|
|
state = json.load(f)
|
|
|
|
cb = state['circuitBreakers']['eng-backend']
|
|
|
|
# Add 2 more failures to reach threshold
|
|
cb['failures'] += 2
|
|
cb['lastFailure'] = datetime.utcnow().isoformat() + 'Z'
|
|
|
|
# Check if threshold reached
|
|
if cb['failures'] >= FAILURE_THRESHOLD:
|
|
cb['state'] = 'open'
|
|
cb['cooldownUntil'] = (datetime.utcnow() + timedelta(seconds=COOLDOWN_SECONDS)).isoformat() + 'Z'
|
|
print(f"TRIPPED:open")
|
|
else:
|
|
print(f"NOT_TRIPPED:{cb['failures']}")
|
|
|
|
with open('.loki/state/orchestrator.json', 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
EOF
|
|
|
|
cb_state=$(python3 -c "
|
|
import json
|
|
data = json.load(open('.loki/state/orchestrator.json'))
|
|
print(data['circuitBreakers']['eng-backend']['state'])
|
|
")
|
|
|
|
if [ "$cb_state" = "open" ]; then
|
|
log_pass "Circuit breaker tripped to OPEN"
|
|
else
|
|
log_fail "Expected OPEN, got $cb_state"
|
|
fi
|
|
|
|
# Test 4: Block requests when OPEN
|
|
log_test "Block requests when circuit is OPEN"
|
|
python3 << 'EOF'
|
|
import json
|
|
from datetime import datetime
|
|
|
|
with open('.loki/state/orchestrator.json', 'r') as f:
|
|
state = json.load(f)
|
|
|
|
cb = state['circuitBreakers']['eng-backend']
|
|
|
|
def can_proceed(circuit_breaker):
|
|
if circuit_breaker['state'] == 'closed':
|
|
return True
|
|
if circuit_breaker['state'] == 'open':
|
|
cooldown = circuit_breaker.get('cooldownUntil')
|
|
if cooldown:
|
|
# Check if cooldown expired
|
|
cooldown_time = datetime.fromisoformat(cooldown.replace('Z', '+00:00'))
|
|
if datetime.now(cooldown_time.tzinfo) > cooldown_time:
|
|
return True # Can transition to half-open
|
|
return False
|
|
if circuit_breaker['state'] == 'half-open':
|
|
return True
|
|
return False
|
|
|
|
result = can_proceed(cb)
|
|
print("BLOCKED" if not result else "ALLOWED")
|
|
EOF
|
|
|
|
log_pass "Requests blocked when circuit is OPEN"
|
|
|
|
# Test 5: Transition to HALF-OPEN after cooldown
|
|
log_test "Transition to HALF-OPEN after cooldown"
|
|
python3 << 'EOF'
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
with open('.loki/state/orchestrator.json', 'r') as f:
|
|
state = json.load(f)
|
|
|
|
cb = state['circuitBreakers']['eng-backend']
|
|
|
|
# Simulate cooldown expired
|
|
cb['cooldownUntil'] = (datetime.utcnow() - timedelta(seconds=10)).isoformat() + 'Z'
|
|
|
|
# Check and transition
|
|
cooldown_time = datetime.fromisoformat(cb['cooldownUntil'].replace('Z', '+00:00'))
|
|
if datetime.now(cooldown_time.tzinfo) > cooldown_time and cb['state'] == 'open':
|
|
cb['state'] = 'half-open'
|
|
cb['halfOpenAttempts'] = 0
|
|
print("TRANSITIONED:half-open")
|
|
|
|
with open('.loki/state/orchestrator.json', 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
EOF
|
|
|
|
cb_state=$(python3 -c "
|
|
import json
|
|
data = json.load(open('.loki/state/orchestrator.json'))
|
|
print(data['circuitBreakers']['eng-backend']['state'])
|
|
")
|
|
|
|
if [ "$cb_state" = "half-open" ]; then
|
|
log_pass "Circuit breaker transitioned to HALF-OPEN"
|
|
else
|
|
log_fail "Expected HALF-OPEN, got $cb_state"
|
|
fi
|
|
|
|
# Test 6: Success in HALF-OPEN -> CLOSED
|
|
log_test "Success in HALF-OPEN transitions to CLOSED"
|
|
python3 << 'EOF'
|
|
import json
|
|
|
|
HALF_OPEN_REQUESTS = 3
|
|
|
|
with open('.loki/state/orchestrator.json', 'r') as f:
|
|
state = json.load(f)
|
|
|
|
cb = state['circuitBreakers']['eng-backend']
|
|
|
|
# Simulate successful requests in half-open
|
|
for i in range(HALF_OPEN_REQUESTS):
|
|
cb['halfOpenAttempts'] += 1
|
|
|
|
# After enough successes, transition to closed
|
|
if cb['halfOpenAttempts'] >= HALF_OPEN_REQUESTS:
|
|
cb['state'] = 'closed'
|
|
cb['failures'] = 0
|
|
cb['lastFailure'] = None
|
|
cb['cooldownUntil'] = None
|
|
cb['halfOpenAttempts'] = 0
|
|
print("RECOVERED:closed")
|
|
|
|
with open('.loki/state/orchestrator.json', 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
EOF
|
|
|
|
cb_state=$(python3 -c "
|
|
import json
|
|
data = json.load(open('.loki/state/orchestrator.json'))
|
|
print(data['circuitBreakers']['eng-backend']['state'])
|
|
")
|
|
|
|
if [ "$cb_state" = "closed" ]; then
|
|
log_pass "Circuit breaker recovered to CLOSED"
|
|
else
|
|
log_fail "Expected CLOSED, got $cb_state"
|
|
fi
|
|
|
|
# Test 7: Failure in HALF-OPEN -> OPEN
|
|
log_test "Failure in HALF-OPEN transitions back to OPEN"
|
|
python3 << 'EOF'
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
COOLDOWN_SECONDS = 300
|
|
|
|
with open('.loki/state/orchestrator.json', 'r') as f:
|
|
state = json.load(f)
|
|
|
|
cb = state['circuitBreakers']['eng-backend']
|
|
|
|
# Set to half-open
|
|
cb['state'] = 'half-open'
|
|
cb['halfOpenAttempts'] = 1
|
|
|
|
# Simulate failure
|
|
cb['state'] = 'open'
|
|
cb['failures'] += 1
|
|
cb['lastFailure'] = datetime.utcnow().isoformat() + 'Z'
|
|
cb['cooldownUntil'] = (datetime.utcnow() + timedelta(seconds=COOLDOWN_SECONDS)).isoformat() + 'Z'
|
|
cb['halfOpenAttempts'] = 0
|
|
|
|
print("REOPENED")
|
|
|
|
with open('.loki/state/orchestrator.json', 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
EOF
|
|
|
|
cb_state=$(python3 -c "
|
|
import json
|
|
data = json.load(open('.loki/state/orchestrator.json'))
|
|
print(data['circuitBreakers']['eng-backend']['state'])
|
|
")
|
|
|
|
if [ "$cb_state" = "open" ]; then
|
|
log_pass "Circuit breaker reopened after HALF-OPEN failure"
|
|
else
|
|
log_fail "Expected OPEN, got $cb_state"
|
|
fi
|
|
|
|
# Test 8: Per-agent-type thresholds
|
|
log_test "Per-agent-type thresholds from config"
|
|
python3 << 'EOF'
|
|
import json
|
|
|
|
# Simulate reading config (in real usage, would parse YAML)
|
|
config = {
|
|
'defaults': {
|
|
'failureThreshold': 5,
|
|
'cooldownSeconds': 300
|
|
},
|
|
'overrides': {
|
|
'external-api': {
|
|
'failureThreshold': 3,
|
|
'cooldownSeconds': 600
|
|
},
|
|
'eng-frontend': {
|
|
'failureThreshold': 10,
|
|
'cooldownSeconds': 180
|
|
}
|
|
}
|
|
}
|
|
|
|
def get_threshold(agent_type):
|
|
if agent_type in config['overrides']:
|
|
return config['overrides'][agent_type].get('failureThreshold', config['defaults']['failureThreshold'])
|
|
return config['defaults']['failureThreshold']
|
|
|
|
# Test different agent types
|
|
backend_threshold = get_threshold('eng-backend') # Should use default
|
|
frontend_threshold = get_threshold('eng-frontend') # Should use override
|
|
api_threshold = get_threshold('external-api') # Should use override
|
|
|
|
results = {
|
|
'eng-backend': backend_threshold,
|
|
'eng-frontend': frontend_threshold,
|
|
'external-api': api_threshold
|
|
}
|
|
|
|
print(f"THRESHOLDS:backend={backend_threshold},frontend={frontend_threshold},api={api_threshold}")
|
|
|
|
# Verify
|
|
assert backend_threshold == 5, f"Expected 5, got {backend_threshold}"
|
|
assert frontend_threshold == 10, f"Expected 10, got {frontend_threshold}"
|
|
assert api_threshold == 3, f"Expected 3, got {api_threshold}"
|
|
|
|
print("VERIFIED")
|
|
EOF
|
|
|
|
log_pass "Per-agent-type thresholds work correctly"
|
|
|
|
echo ""
|
|
echo "========================================"
|
|
echo "Test Summary"
|
|
echo "========================================"
|
|
echo -e "${GREEN}Passed: $PASSED${NC}"
|
|
echo -e "${RED}Failed: $FAILED${NC}"
|
|
echo ""
|
|
|
|
if [ $FAILED -eq 0 ]; then
|
|
echo -e "${GREEN}All tests passed!${NC}"
|
|
exit 0
|
|
else
|
|
echo -e "${RED}Some tests failed!${NC}"
|
|
exit 1
|
|
fi
|