Skip to content

Commit ba8e69f

Browse files
feat: expose circuit breaker transition metrics
1 parent 61fa577 commit ba8e69f

4 files changed

Lines changed: 75 additions & 135 deletions

File tree

docs/starknet/circuit-breaker.md

Lines changed: 11 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,14 @@
1-
# Starknet Circuit Breaker
1+
# Circuit-breaker metrics
22

3-
## Overview
3+
The Starknet client exposes two process-local metrics for each RPC endpoint:
44

5-
The circuit breaker pattern prevents cascading failures and reduces latency by failing fast when an RPC endpoint is unhealthy. Each Starknet RPC endpoint is protected by its own circuit breaker instance that tracks failures and manages state transitions.
5+
- `starknet_circuit_breaker_state`, a gauge where `0=CLOSED`, `1=OPEN`, and
6+
`2=HALF_OPEN`.
7+
- `starknet_circuit_breaker_transitions_total`, a counter labeled with the
8+
endpoint and transition name, such as `CLOSED_to_OPEN`.
69

7-
## States
8-
9-
### CLOSED (Normal Operation)
10-
- All calls pass through normally
11-
- Recent failures are tracked in a rolling time window
12-
- Transitions to OPEN when failures reach the configured threshold
13-
14-
### OPEN (Fail-Fast)
15-
- All calls are immediately rejected with `CircuitOpenError`
16-
- No actual RPC calls are made, saving timeout costs
17-
- After a cooldown period, transitions to HALF_OPEN to test recovery
18-
19-
### HALF_OPEN (Recovery Probe)
20-
- A single probe call is allowed through to test endpoint health
21-
- Success: Accumulates toward `successThreshold` to close the circuit
22-
- Failure: Immediately reopens the circuit and resets the cooldown
23-
24-
## Configuration
25-
26-
Circuit breaker behavior is controlled via environment variables:
27-
28-
```bash
29-
# Number of failures before circuit opens (default: 5)
30-
CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
31-
32-
# Number of successes needed to close from HALF_OPEN (default: 2)
33-
CIRCUIT_BREAKER_SUCCESS_THRESHOLD=2
34-
35-
# Cooldown period before attempting recovery (default: 30000ms = 30s)
36-
CIRCUIT_BREAKER_COOLDOWN_MS=30000
37-
38-
# Rolling time window for counting failures (default: 60000ms = 60s)
39-
CIRCUIT_BREAKER_WINDOW_MS=60000
40-
```
41-
42-
## Integration with Failover
43-
44-
The circuit breaker composes with the existing RPC failover logic:
45-
46-
1. **Circuit Open**: When a circuit is open, `invokeWithFailover` immediately skips that endpoint and tries the next one in the failover order
47-
2. **Failover Triggered**: If the primary endpoint's circuit opens, subsequent calls automatically use the healthy secondary endpoint
48-
3. **Recovery**: After the cooldown, the circuit attempts recovery with a probe call
49-
50-
This combination provides both fast failure detection (circuit breaker) and automatic endpoint switching (failover).
51-
52-
## Monitoring
53-
54-
Circuit breaker state is exposed in the diagnostics endpoint (`GET /diagnostics/events`):
55-
56-
```json
57-
{
58-
"circuitBreakers": [
59-
{
60-
"endpointUrl": "https://rpc.example.com",
61-
"state": "CLOSED",
62-
"recentFailureCount": 0,
63-
"openedAt": null
64-
}
65-
]
66-
}
67-
```
68-
69-
### State Fields
70-
71-
- **endpointUrl**: The RPC endpoint URL
72-
- **state**: Current circuit state (`CLOSED`, `OPEN`, or `HALF_OPEN`)
73-
- **recentFailureCount**: Number of failures in the current rolling window
74-
- **openedAt**: Timestamp when circuit opened (milliseconds since epoch), or `null` if closed
75-
76-
## Example Scenarios
77-
78-
### Scenario 1: Endpoint Degrades
79-
1. RPC endpoint starts returning timeouts
80-
2. After 5 failures in 60 seconds, circuit opens
81-
3. Subsequent calls skip this endpoint immediately
82-
4. Failover to secondary endpoint succeeds
83-
5. After 30 seconds, circuit attempts recovery
84-
6. 2 successful probe calls close the circuit
85-
7. Endpoint returns to normal rotation
86-
87-
### Scenario 2: Temporary Network Blip
88-
1. 2 failures occur due to temporary network issue
89-
2. Circuit remains CLOSED (below threshold of 5)
90-
3. After 60 seconds, these failures age out of the window
91-
4. No circuit opening occurs
92-
93-
### Scenario 3: All Endpoints Down
94-
1. Primary circuit opens after 5 failures
95-
2. Failover to secondary
96-
3. Secondary also fails 5 times and circuit opens
97-
4. All circuits open, final request throws error
98-
5. After cooldown, both circuits attempt recovery
99-
6. First endpoint to recover closes its circuit and handles requests
100-
101-
## Implementation Details
102-
103-
### Failure Tracking
104-
105-
Failures are tracked with timestamps in a rolling window. Old failures outside the window are automatically pruned, ensuring the circuit only reacts to recent patterns.
106-
107-
```typescript
108-
// Example: 3 failures at t=0, t=30s, t=120s with 60s window
109-
// At t=120s: Only the t=30s and t=120s failures count (2 total)
110-
// The t=0 failure is outside the 60s window and is pruned
111-
```
112-
113-
### Half-Open Behavior
114-
115-
In HALF_OPEN state, only one probe call is allowed through at a time. This prevents a "thundering herd" of simultaneous recovery attempts that could overwhelm a recovering endpoint.
116-
117-
### Success Threshold Rationale
118-
119-
The `successThreshold` of 2 (default) prevents premature circuit closure from a single lucky success. Two consecutive successes provide more confidence that the endpoint has genuinely recovered.
120-
121-
## Testing
122-
123-
The circuit breaker is tested at two levels:
124-
125-
1. **Unit Tests** (`circuit-breaker.test.ts`): Test state transitions, threshold logic, and time windows
126-
2. **Integration Tests** (`client.test.ts`): Test interaction with failover, proxy behavior, and end-to-end flows
127-
128-
All tests achieve >95% coverage as required by project guidelines.
129-
130-
## Security Considerations
131-
132-
The circuit breaker improves security posture by:
133-
134-
1. **Reducing Attack Surface**: Open circuits prevent repeated calls to potentially compromised endpoints
135-
2. **DoS Mitigation**: Fail-fast behavior prevents resource exhaustion from hanging calls
136-
3. **Observable State**: Circuit state is exposed in diagnostics for operator visibility
137-
138-
Circuit state does not leak sensitive information (e.g., request contents or user data) and is safe to expose in admin-only diagnostics endpoints.
10+
Alert when an endpoint remains `OPEN` (state `1`) for longer than the normal
11+
RPC recovery window, or when `CLOSED_to_OPEN` transitions repeat rapidly. A
12+
useful starting threshold is state `1` for more than five minutes, adjusted
13+
for the configured circuit-breaker cooldown and the number of healthy fallback
14+
endpoints.

src/starknet/circuit-breaker.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
snapshotCircuitBreaker,
66
type CircuitBreakerOptions,
77
} from "./circuit-breaker.js";
8+
import {
9+
getStarknetMetricsSnapshot,
10+
resetStarknetMetrics,
11+
} from "./client-metrics.js";
812

913
const ENDPOINT = "https://rpc.example.com";
1014

@@ -40,6 +44,23 @@ describe("CircuitOpenError", () => {
4044
});
4145
});
4246

47+
describe("circuit breaker metrics", () => {
48+
beforeEach(() => resetStarknetMetrics());
49+
50+
it("records the current state and every transition with endpoint labels", () => {
51+
const breaker = makeBreaker({ failureThreshold: 1 });
52+
breaker.recordFailure();
53+
54+
const { gauges, counters } = getStarknetMetricsSnapshot();
55+
expect(gauges['starknet_circuit_breaker_state{endpoint="https://rpc.example.com"}']).toBe(1);
56+
expect(
57+
counters[
58+
'starknet_circuit_breaker_transitions_total{endpoint="https://rpc.example.com",transition="CLOSED_to_OPEN"}'
59+
],
60+
).toBe(1);
61+
});
62+
});
63+
4364
// ---------------------------------------------------------------------------
4465
// Initial state
4566
// ---------------------------------------------------------------------------

src/starknet/circuit-breaker.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,21 @@
1515
* → on failure: resets to OPEN, starting the cooldown again
1616
*/
1717

18+
import {
19+
incStarknetMetric,
20+
labeledStarknetMetric,
21+
setStarknetGauge,
22+
STARKNET_METRICS,
23+
} from "./client-metrics.js";
24+
1825
export type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
1926

27+
const CIRCUIT_STATE_VALUE: Record<CircuitState, number> = {
28+
CLOSED: 0,
29+
OPEN: 1,
30+
HALF_OPEN: 2,
31+
};
32+
2033
export interface CircuitBreakerOptions {
2134
/** Number of failures in the rolling window that open the circuit. */
2235
failureThreshold: number;
@@ -51,6 +64,7 @@ export class EndpointCircuitBreaker {
5164
constructor(endpointUrl: string, options: CircuitBreakerOptions) {
5265
this.endpointUrl = endpointUrl;
5366
this.options = options;
67+
this.recordStateMetric("CLOSED");
5468
}
5569

5670
/** Current circuit state. */
@@ -148,6 +162,13 @@ export class EndpointCircuitBreaker {
148162
private transitionTo(next: CircuitState): void {
149163
const previous = this.state;
150164
this.state = next;
165+
this.recordStateMetric(next);
166+
incStarknetMetric(
167+
labeledStarknetMetric(STARKNET_METRICS.CIRCUIT_BREAKER_TRANSITIONS, {
168+
endpoint: this.endpointUrl,
169+
transition: `${previous}_to_${next}`,
170+
}),
171+
);
151172

152173
if (next === "OPEN") {
153174
this.openedAt = Date.now();
@@ -174,6 +195,15 @@ export class EndpointCircuitBreaker {
174195
}
175196
}
176197

198+
private recordStateMetric(state: CircuitState): void {
199+
setStarknetGauge(
200+
labeledStarknetMetric(STARKNET_METRICS.CIRCUIT_BREAKER_STATE, {
201+
endpoint: this.endpointUrl,
202+
}),
203+
CIRCUIT_STATE_VALUE[state],
204+
);
205+
}
206+
177207
private pruneOldFailures(): void {
178208
const cutoff = Date.now() - this.options.windowMs;
179209
this.failureTimestamps = this.failureTimestamps.filter((ts) => ts > cutoff);

src/starknet/client-metrics.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ export function resetStarknetMetrics(): void {
8787
for (const k of Object.keys(gauges)) delete gauges[k];
8888
}
8989

90+
/** Build a Prometheus-style metric key without allowing label syntax injection. */
91+
export function labeledStarknetMetric(
92+
name: string,
93+
labels: Record<string, string>,
94+
): string {
95+
const encoded = Object.entries(labels)
96+
.map(([key, value]) => `${key}="${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`)
97+
.join(",");
98+
return `${name}{${encoded}}`;
99+
}
100+
90101
// ---------------------------------------------------------------------------
91102
// Structured logging
92103
// ---------------------------------------------------------------------------
@@ -146,4 +157,6 @@ export const STARKNET_METRICS = {
146157
NETWORK_INFO_FETCHES: "starknet_network_info_fetches_total",
147158
NETWORK_INFO_DEDUPED: "starknet_network_info_deduped_total",
148159
NETWORK_INFO_ERRORS: "starknet_network_info_errors_total",
160+
CIRCUIT_BREAKER_STATE: "starknet_circuit_breaker_state",
161+
CIRCUIT_BREAKER_TRANSITIONS: "starknet_circuit_breaker_transitions_total",
149162
} as const;

0 commit comments

Comments
 (0)