Skip to content

Commit 3bcb102

Browse files
style: run biome formatting across resilience pipeline
1 parent e1b3d1f commit 3bcb102

File tree

10 files changed

+686
-697
lines changed

10 files changed

+686
-697
lines changed
Lines changed: 116 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,143 @@
1-
import type { McpExecutionContext, McpMiddleware, McpMiddlewareNext } from './types'
2-
import type { McpToolResult } from '@/lib/mcp/types'
31
import { createLogger } from '@sim/logger'
2+
import type { McpToolResult } from '@/lib/mcp/types'
3+
import type { McpExecutionContext, McpMiddleware, McpMiddlewareNext } from './types'
44

55
// Configure standard cache size limit
66
const MAX_SERVER_STATES = 1000
77

88
export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF-OPEN'
99

1010
export interface CircuitBreakerConfig {
11-
/** Number of failures before tripping to OPEN */
12-
failureThreshold: number
13-
/** How long to wait in OPEN before transitioning to HALF-OPEN (ms) */
14-
resetTimeoutMs: number
11+
/** Number of failures before tripping to OPEN */
12+
failureThreshold: number
13+
/** How long to wait in OPEN before transitioning to HALF-OPEN (ms) */
14+
resetTimeoutMs: number
1515
}
1616

1717
interface ServerState {
18-
state: CircuitState
19-
failures: number
20-
nextAttemptMs: number
21-
isHalfOpenProbing: boolean
18+
state: CircuitState
19+
failures: number
20+
nextAttemptMs: number
21+
isHalfOpenProbing: boolean
2222
}
2323

2424
const logger = createLogger('mcp:resilience:circuit-breaker')
2525

2626
export class CircuitBreakerMiddleware implements McpMiddleware {
27-
// Use a Map to maintain insertion order for standard LRU-like eviction if necessary.
28-
// We constrain it to prevent memory leaks if thousands of ephemeral servers connect.
29-
private registry = new Map<string, ServerState>()
30-
private config: CircuitBreakerConfig
31-
32-
constructor(config: Partial<CircuitBreakerConfig> = {}) {
33-
this.config = {
34-
failureThreshold: config.failureThreshold ?? 5,
35-
resetTimeoutMs: config.resetTimeoutMs ?? 30000,
36-
}
27+
// Use a Map to maintain insertion order for standard LRU-like eviction if necessary.
28+
// We constrain it to prevent memory leaks if thousands of ephemeral servers connect.
29+
private registry = new Map<string, ServerState>()
30+
private config: CircuitBreakerConfig
31+
32+
constructor(config: Partial<CircuitBreakerConfig> = {}) {
33+
this.config = {
34+
failureThreshold: config.failureThreshold ?? 5,
35+
resetTimeoutMs: config.resetTimeoutMs ?? 30000,
3736
}
38-
39-
private getState(serverId: string): ServerState {
40-
let state = this.registry.get(serverId)
41-
if (!state) {
42-
state = {
43-
state: 'CLOSED',
44-
failures: 0,
45-
nextAttemptMs: 0,
46-
isHalfOpenProbing: false,
47-
}
48-
this.registry.set(serverId, state)
49-
this.evictIfNecessary()
50-
}
51-
return state
37+
}
38+
39+
private getState(serverId: string): ServerState {
40+
let state = this.registry.get(serverId)
41+
if (!state) {
42+
state = {
43+
state: 'CLOSED',
44+
failures: 0,
45+
nextAttemptMs: 0,
46+
isHalfOpenProbing: false,
47+
}
48+
this.registry.set(serverId, state)
49+
this.evictIfNecessary()
5250
}
53-
54-
private evictIfNecessary() {
55-
if (this.registry.size > MAX_SERVER_STATES) {
56-
// Evict the oldest entry (first inserted)
57-
const firstKey = this.registry.keys().next().value
58-
if (firstKey) {
59-
this.registry.delete(firstKey)
60-
}
61-
}
51+
return state
52+
}
53+
54+
private evictIfNecessary() {
55+
if (this.registry.size > MAX_SERVER_STATES) {
56+
// Evict the oldest entry (first inserted)
57+
const firstKey = this.registry.keys().next().value
58+
if (firstKey) {
59+
this.registry.delete(firstKey)
60+
}
6261
}
63-
64-
async execute(
65-
context: McpExecutionContext,
66-
next: McpMiddlewareNext
67-
): Promise<McpToolResult> {
68-
const { serverId, toolCall } = context
69-
const serverState = this.getState(serverId)
70-
71-
// 1. Check current state and evaluate timeouts
72-
if (serverState.state === 'OPEN') {
73-
if (Date.now() > serverState.nextAttemptMs) {
74-
// Time to try again, enter HALF-OPEN
75-
logger.info(`Circuit breaker entering HALF-OPEN for server ${serverId}`)
76-
serverState.state = 'HALF-OPEN'
77-
serverState.isHalfOpenProbing = false
78-
} else {
79-
// Fast-fail
80-
throw new Error(
81-
`Circuit breaker is OPEN for server ${serverId}. Fast-failing request to ${toolCall.name}.`
82-
)
83-
}
84-
}
85-
86-
if (serverState.state === 'HALF-OPEN') {
87-
if (serverState.isHalfOpenProbing) {
88-
// Another request is already probing. Fast-fail concurrent requests.
89-
throw new Error(
90-
`Circuit breaker is HALF-OPEN for server ${serverId}. A probe request is currently executing. Fast-failing concurrent request to ${toolCall.name}.`
91-
)
92-
}
93-
// We are the chosen ones. Lock it down.
94-
serverState.isHalfOpenProbing = true
95-
}
96-
97-
try {
98-
// 2. Invoke the next layer
99-
const result = await next(context)
100-
101-
// 3. Handle result parsing (isError = true counts as failure for us)
102-
if (result.isError) {
103-
this.recordFailure(serverId, serverState)
104-
} else {
105-
this.recordSuccess(serverId, serverState)
106-
}
107-
108-
return result
109-
} catch (error) {
110-
// Note: we record failure on ANY exception
111-
this.recordFailure(serverId, serverState)
112-
throw error // Re-throw to caller
113-
}
62+
}
63+
64+
async execute(context: McpExecutionContext, next: McpMiddlewareNext): Promise<McpToolResult> {
65+
const { serverId, toolCall } = context
66+
const serverState = this.getState(serverId)
67+
68+
// 1. Check current state and evaluate timeouts
69+
if (serverState.state === 'OPEN') {
70+
if (Date.now() > serverState.nextAttemptMs) {
71+
// Time to try again, enter HALF-OPEN
72+
logger.info(`Circuit breaker entering HALF-OPEN for server ${serverId}`)
73+
serverState.state = 'HALF-OPEN'
74+
serverState.isHalfOpenProbing = false
75+
} else {
76+
// Fast-fail
77+
throw new Error(
78+
`Circuit breaker is OPEN for server ${serverId}. Fast-failing request to ${toolCall.name}.`
79+
)
80+
}
11481
}
11582

116-
private recordSuccess(serverId: string, state: ServerState) {
117-
if (state.state !== 'CLOSED') {
118-
logger.info(`Circuit breaker reset to CLOSED for server ${serverId}`)
119-
}
120-
state.state = 'CLOSED'
121-
state.failures = 0
122-
state.isHalfOpenProbing = false
83+
if (serverState.state === 'HALF-OPEN') {
84+
if (serverState.isHalfOpenProbing) {
85+
// Another request is already probing. Fast-fail concurrent requests.
86+
throw new Error(
87+
`Circuit breaker is HALF-OPEN for server ${serverId}. A probe request is currently executing. Fast-failing concurrent request to ${toolCall.name}.`
88+
)
89+
}
90+
// We are the chosen ones. Lock it down.
91+
serverState.isHalfOpenProbing = true
12392
}
12493

125-
private recordFailure(serverId: string, state: ServerState) {
126-
if (state.state === 'HALF-OPEN') {
127-
// The probe failed! Trip immediately back to OPEN.
128-
logger.warn(
129-
`Circuit breaker probe failed. Tripping back to OPEN for server ${serverId}`
130-
)
131-
this.tripToOpen(state)
132-
} else if (state.state === 'CLOSED') {
133-
state.failures++
134-
if (state.failures >= this.config.failureThreshold) {
135-
logger.error(
136-
`Circuit breaker failure threshold reached (${state.failures}/${this.config.failureThreshold}). Tripping to OPEN for server ${serverId}`
137-
)
138-
this.tripToOpen(state)
139-
}
140-
}
94+
try {
95+
// 2. Invoke the next layer
96+
const result = await next(context)
97+
98+
// 3. Handle result parsing (isError = true counts as failure for us)
99+
if (result.isError) {
100+
this.recordFailure(serverId, serverState)
101+
} else {
102+
this.recordSuccess(serverId, serverState)
103+
}
104+
105+
return result
106+
} catch (error) {
107+
// Note: we record failure on ANY exception
108+
this.recordFailure(serverId, serverState)
109+
throw error // Re-throw to caller
141110
}
111+
}
142112

143-
private tripToOpen(state: ServerState) {
144-
state.state = 'OPEN'
145-
state.isHalfOpenProbing = false
146-
state.nextAttemptMs = Date.now() + this.config.resetTimeoutMs
113+
private recordSuccess(serverId: string, state: ServerState) {
114+
if (state.state !== 'CLOSED') {
115+
logger.info(`Circuit breaker reset to CLOSED for server ${serverId}`)
147116
}
117+
state.state = 'CLOSED'
118+
state.failures = 0
119+
state.isHalfOpenProbing = false
120+
}
121+
122+
private recordFailure(serverId: string, state: ServerState) {
123+
if (state.state === 'HALF-OPEN') {
124+
// The probe failed! Trip immediately back to OPEN.
125+
logger.warn(`Circuit breaker probe failed. Tripping back to OPEN for server ${serverId}`)
126+
this.tripToOpen(state)
127+
} else if (state.state === 'CLOSED') {
128+
state.failures++
129+
if (state.failures >= this.config.failureThreshold) {
130+
logger.error(
131+
`Circuit breaker failure threshold reached (${state.failures}/${this.config.failureThreshold}). Tripping to OPEN for server ${serverId}`
132+
)
133+
this.tripToOpen(state)
134+
}
135+
}
136+
}
137+
138+
private tripToOpen(state: ServerState) {
139+
state.state = 'OPEN'
140+
state.isHalfOpenProbing = false
141+
state.nextAttemptMs = Date.now() + this.config.resetTimeoutMs
142+
}
148143
}

0 commit comments

Comments
 (0)