Skip to content

Commit 4aceb32

Browse files
Handle intermittent interpreter failures and decouple jupyter startup (#51)
* Make jupyter startup non-blocking * Handle cascading failures * Add changeset
1 parent bb855ca commit 4aceb32

File tree

10 files changed

+1708
-327
lines changed

10 files changed

+1708
-327
lines changed

.changeset/brown-views-teach.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/sandbox": patch
3+
---
4+
5+
Handle intermittent interpreter failures and decouple jupyter startup

examples/basic/src/endpoints/notebook.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Sandbox } from "@cloudflare/sandbox";
2+
import { JupyterNotReadyError, isJupyterNotReadyError, isRetryableError } from "@cloudflare/sandbox";
23
import { corsHeaders, errorResponse, jsonResponse, parseJsonBody } from "../http";
34

45
// Active sessions (in production, use Durable Objects or KV)
@@ -21,6 +22,33 @@ export async function createSession(sandbox: Sandbox, request: Request): Promise
2122

2223
return jsonResponse({ sessionId, language });
2324
} catch (error: any) {
25+
// Handle Jupyter initialization timeout (request waited but Jupyter wasn't ready in time)
26+
if (isJupyterNotReadyError(error)) {
27+
console.log("[Notebook] Request timed out waiting for Jupyter initialization");
28+
return new Response(
29+
JSON.stringify({
30+
error: error.message,
31+
retryAfter: error.retryAfter,
32+
progress: error.progress
33+
}),
34+
{
35+
status: 503,
36+
headers: {
37+
"Content-Type": "application/json",
38+
"Retry-After": String(error.retryAfter),
39+
...corsHeaders()
40+
}
41+
}
42+
);
43+
}
44+
45+
// Check if error is retryable
46+
if (isRetryableError(error)) {
47+
console.log("[Notebook] Retryable error:", error.message);
48+
return errorResponse(error.message, 503);
49+
}
50+
51+
// Log actual errors
2452
console.error("Create session error:", error);
2553
return errorResponse(error.message || "Failed to create session", 500);
2654
}
@@ -94,6 +122,33 @@ export async function executeCell(sandbox: Sandbox, request: Request): Promise<R
94122
}
95123
});
96124
} catch (error: any) {
125+
// Handle Jupyter initialization timeout (request waited but Jupyter wasn't ready in time)
126+
if (isJupyterNotReadyError(error)) {
127+
console.log("[Notebook] Request timed out waiting for Jupyter initialization");
128+
return new Response(
129+
JSON.stringify({
130+
error: error.message,
131+
retryAfter: error.retryAfter,
132+
progress: error.progress
133+
}),
134+
{
135+
status: 503,
136+
headers: {
137+
"Content-Type": "application/json",
138+
"Retry-After": String(error.retryAfter),
139+
...corsHeaders()
140+
}
141+
}
142+
);
143+
}
144+
145+
// Check if error is retryable
146+
if (isRetryableError(error)) {
147+
console.log("[Notebook] Retryable error:", error.message);
148+
return errorResponse(error.message, 503);
149+
}
150+
151+
// Log actual errors
97152
console.error("Execute cell error:", error);
98153
return errorResponse(error.message || "Failed to execute code", 500);
99154
}
@@ -113,6 +168,33 @@ export async function deleteSession(sandbox: Sandbox, request: Request): Promise
113168

114169
return jsonResponse({ success: true });
115170
} catch (error: any) {
171+
// Handle Jupyter initialization timeout (request waited but Jupyter wasn't ready in time)
172+
if (isJupyterNotReadyError(error)) {
173+
console.log("[Notebook] Request timed out waiting for Jupyter initialization");
174+
return new Response(
175+
JSON.stringify({
176+
error: error.message,
177+
retryAfter: error.retryAfter,
178+
progress: error.progress
179+
}),
180+
{
181+
status: 503,
182+
headers: {
183+
"Content-Type": "application/json",
184+
"Retry-After": String(error.retryAfter),
185+
...corsHeaders()
186+
}
187+
}
188+
);
189+
}
190+
191+
// Check if error is retryable
192+
if (isRetryableError(error)) {
193+
console.log("[Notebook] Retryable error:", error.message);
194+
return errorResponse(error.message, 503);
195+
}
196+
197+
// Log actual errors
116198
console.error("Delete session error:", error);
117199
return errorResponse(error.message || "Failed to delete session", 500);
118200
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Circuit Breaker implementation to prevent cascading failures
3+
*/
4+
export class CircuitBreaker {
5+
private failures = 0;
6+
private lastFailure: number = 0;
7+
private successCount = 0;
8+
private state: "closed" | "open" | "half-open" = "closed";
9+
10+
// Configuration
11+
private readonly threshold: number;
12+
private readonly timeout: number;
13+
private readonly halfOpenSuccessThreshold: number;
14+
private readonly name: string;
15+
16+
constructor(options: {
17+
name: string;
18+
threshold?: number;
19+
timeout?: number;
20+
halfOpenSuccessThreshold?: number;
21+
}) {
22+
this.name = options.name;
23+
this.threshold = options.threshold || 5;
24+
this.timeout = options.timeout || 30000; // 30 seconds
25+
this.halfOpenSuccessThreshold = options.halfOpenSuccessThreshold || 3;
26+
}
27+
28+
/**
29+
* Execute an operation with circuit breaker protection
30+
*/
31+
async execute<T>(operation: () => Promise<T>): Promise<T> {
32+
// Check circuit state
33+
if (this.state === "open") {
34+
if (Date.now() - this.lastFailure > this.timeout) {
35+
console.log(
36+
`[CircuitBreaker ${this.name}] Transitioning from open to half-open`
37+
);
38+
this.state = "half-open";
39+
this.successCount = 0;
40+
} else {
41+
throw new Error(
42+
`Circuit breaker is open for ${this.name}. Retry after ${
43+
this.timeout - (Date.now() - this.lastFailure)
44+
}ms`
45+
);
46+
}
47+
}
48+
49+
try {
50+
const result = await operation();
51+
52+
// Record success
53+
if (this.state === "half-open") {
54+
this.successCount++;
55+
if (this.successCount >= this.halfOpenSuccessThreshold) {
56+
console.log(
57+
`[CircuitBreaker ${this.name}] Transitioning from half-open to closed`
58+
);
59+
this.state = "closed";
60+
this.failures = 0;
61+
}
62+
} else if (this.state === "closed") {
63+
// Reset failure count on success
64+
this.failures = 0;
65+
}
66+
67+
return result;
68+
} catch (error) {
69+
this.recordFailure();
70+
throw error;
71+
}
72+
}
73+
74+
/**
75+
* Record a failure and update circuit state
76+
*/
77+
private recordFailure() {
78+
this.failures++;
79+
this.lastFailure = Date.now();
80+
81+
if (this.state === "half-open") {
82+
console.log(
83+
`[CircuitBreaker ${this.name}] Failure in half-open state, transitioning to open`
84+
);
85+
this.state = "open";
86+
} else if (this.failures >= this.threshold) {
87+
console.log(
88+
`[CircuitBreaker ${this.name}] Threshold reached (${this.failures}/${this.threshold}), transitioning to open`
89+
);
90+
this.state = "open";
91+
}
92+
}
93+
94+
/**
95+
* Get current circuit breaker state
96+
*/
97+
getState(): {
98+
state: string;
99+
failures: number;
100+
lastFailure: number;
101+
isOpen: boolean;
102+
} {
103+
return {
104+
state: this.state,
105+
failures: this.failures,
106+
lastFailure: this.lastFailure,
107+
isOpen: this.state === "open",
108+
};
109+
}
110+
111+
/**
112+
* Reset the circuit breaker
113+
*/
114+
reset() {
115+
this.state = "closed";
116+
this.failures = 0;
117+
this.successCount = 0;
118+
this.lastFailure = 0;
119+
console.log(`[CircuitBreaker ${this.name}] Reset to closed state`);
120+
}
121+
}

0 commit comments

Comments
 (0)