Repository navigation
fix(mcp): structured circuit breaker errors to prevent AI retry storms #899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,11 @@ import type { | |
| CircuitBreakerEvents, | ||
| } from "../types/mcpTypes.js"; | ||
|
|
||
| import { CircuitBreakerOpenError } from "../types/circuitBreakerErrors.js"; | ||
|
|
||
| // Re-export CircuitBreakerOpenError from shared types to preserve public API | ||
| export { CircuitBreakerOpenError } from "../types/circuitBreakerErrors.js"; | ||
|
|
||
| /** | ||
| * Default operation timeout for circuit breaker protected operations. | ||
| * Configurable via MCP_OPERATION_TIMEOUT env var (in milliseconds). | ||
|
|
@@ -69,10 +74,18 @@ export class MCPCircuitBreaker extends EventEmitter { | |
| try { | ||
| // Check if circuit is open | ||
| if (this.state === "open") { | ||
| if (Date.now() - this.lastFailureTime < this.config.resetTimeout) { | ||
| throw new Error( | ||
| `Circuit breaker '${this.name}' is open. Next retry at ${new Date(this.lastFailureTime + this.config.resetTimeout)}`, | ||
| ); | ||
| const retryAfterMs = | ||
| this.config.resetTimeout - (Date.now() - this.lastFailureTime); | ||
| if (retryAfterMs > 0) { | ||
| throw new CircuitBreakerOpenError({ | ||
| breakerName: this.name, | ||
| retryAfter: new Date( | ||
| this.lastFailureTime + this.config.resetTimeout, | ||
| ), | ||
| retryAfterMs, | ||
| breakerState: "open", | ||
| failureCount: this.getStats().failedCalls, | ||
| }); | ||
| } | ||
|
|
||
| // Transition to half-open | ||
|
|
@@ -84,9 +97,20 @@ export class MCPCircuitBreaker extends EventEmitter { | |
| this.state === "half-open" && | ||
| this.halfOpenCalls >= this.config.halfOpenMaxCalls | ||
| ) { | ||
| throw new Error( | ||
| `Circuit breaker '${this.name}' is half-open but call limit reached`, | ||
| // Half-open call limit exceeded — revert to open with fresh cooldown | ||
| this.lastFailureTime = Date.now(); | ||
| this.changeState( | ||
| "open", | ||
| "Half-open call limit reached, reverting to open", | ||
| ); | ||
|
|
||
| throw new CircuitBreakerOpenError({ | ||
| breakerName: this.name, | ||
| retryAfter: new Date(this.lastFailureTime + this.config.resetTimeout), | ||
| retryAfterMs: this.config.resetTimeout, | ||
| breakerState: "open", | ||
| failureCount: this.getStats().failedCalls, | ||
| }); | ||
|
Comment on lines
80
to
+113
|
||
| } | ||
|
|
||
| // NLK-GAP-009: Record half-open test event when executing in half-open state | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Semantic mismatch:
failureCountvs "consecutive failures" message.The error message at line 45 says "consecutive failures" but
this.getStats().failedCallsreturns the total failed calls within the statistics window, not consecutive failures. The circuit breaker opens based onfailureThresholdbeing exceeded, but that's compared against total failures in the window, not a consecutive count.Consider either:
🔧 Suggested message fix
super( `Circuit breaker '${options.breakerName}' is ${options.breakerState}. ` + - `Tool temporarily unavailable after ${options.failureCount} consecutive failures. ` + + `Tool temporarily unavailable after ${options.failureCount} failures. ` + `Retry after: ${retryAfterStr} (${Math.ceil(options.retryAfterMs / 1000)}s).`, );🤖 Prompt for AI Agents