Skip to content

Commit 78b5726

Browse files
authored
Merge pull request #58 from homanp/cursor/fix-network-timeout-long-runs
feat: fix network errors on long-running AI streams
2 parents 09fdd35 + 20940f5 commit 78b5726

File tree

4 files changed

+178
-114
lines changed

4 files changed

+178
-114
lines changed

src/app/api/chat/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
import { webSearch, type SearchProvider } from "@/lib/web-search";
1919
import { scanUrls } from "@/lib/brin";
2020

21+
export const maxDuration = 800;
22+
2123
interface McpServerPayload {
2224
name: string;
2325
type: "command" | "sse" | "streamableHttp";

src/app/api/widgets/bootstrap/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
buildWidget,
66
} from "@/lib/widget-runner";
77

8+
export const maxDuration = 800;
9+
810
export async function POST(request: Request) {
911
const { widgets } = (await request.json()) as {
1012
widgets: Array<{

src/components/chat-sidebar.tsx

Lines changed: 146 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ function KittLoader() {
106106
}
107107

108108
const abortControllers = new Map<string, AbortController>();
109+
const MAX_RETRIES = 2;
110+
const RETRY_DELAY_MS = 1500;
109111

110112
function ReasoningBlock({
111113
text,
@@ -288,120 +290,165 @@ async function streamToWidget(
288290
env: s.env,
289291
}));
290292

291-
const res = await fetch("/api/chat", {
292-
method: "POST",
293-
headers: { "Content-Type": "application/json" },
294-
body: JSON.stringify({
295-
messages,
296-
widgetId,
297-
model,
298-
apiKey,
299-
...(searchProvider && searchApiKey ? { searchProvider, searchApiKey } : {}),
300-
...(enabledMcpServers.length > 0 ? { mcpServers: enabledMcpServers } : {}),
301-
...(customApi ? { customApi } : {}),
302-
}),
303-
signal: controller.signal,
293+
const body = JSON.stringify({
294+
messages,
295+
widgetId,
296+
model,
297+
apiKey,
298+
...(searchProvider && searchApiKey ? { searchProvider, searchApiKey } : {}),
299+
...(enabledMcpServers.length > 0 ? { mcpServers: enabledMcpServers } : {}),
300+
...(customApi ? { customApi } : {}),
304301
});
305302

306-
if (!res.ok) {
307-
const err = await res.text();
308-
updateAssistantMessage(widgetId, currentMsgId, `Error: ${err}`);
309-
return;
303+
function isNetworkError(err: unknown): boolean {
304+
if (err instanceof TypeError) return true;
305+
const msg = String(err).toLowerCase();
306+
return msg.includes("network") || msg.includes("failed to fetch") || msg.includes("econnreset") || msg.includes("socket hang up");
310307
}
311308

312-
const reader = res.body?.getReader();
313-
if (!reader) return;
309+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
310+
if (controller.signal.aborted) break;
314311

315-
const decoder = new TextDecoder();
316-
let buffer = "";
312+
if (attempt > 0) {
313+
showAction(`Reconnecting (attempt ${attempt + 1})…`);
314+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * attempt));
315+
if (controller.signal.aborted) break;
316+
}
317317

318-
while (true) {
319-
const { done, value } = await reader.read();
320-
if (done) break;
318+
try {
319+
const res = await fetch("/api/chat", {
320+
method: "POST",
321+
headers: { "Content-Type": "application/json" },
322+
body,
323+
signal: controller.signal,
324+
});
325+
326+
if (!res.ok) {
327+
const err = await res.text();
328+
updateAssistantMessage(widgetId, currentMsgId, `Error: ${err}`);
329+
return;
330+
}
321331

322-
buffer += decoder.decode(value, { stream: true });
323-
const parts = buffer.split("\n\n");
324-
buffer = parts.pop() ?? "";
332+
const reader = res.body?.getReader();
333+
if (!reader) return;
325334

326-
for (const part of parts) {
327-
const line = part.trim();
328-
if (!line.startsWith("data:")) continue;
329-
const payload = line.slice(5).trim();
330-
if (payload === "[DONE]") continue;
335+
const decoder = new TextDecoder();
336+
let buffer = "";
337+
let streamCompleted = false;
331338

332-
try {
333-
const event = JSON.parse(payload);
334-
if (event.type === "reasoning-delta") {
335-
if (hasEmittedText) {
336-
startNewAssistantMessage();
337-
}
338-
setReasoningStreaming(widgetId, true);
339-
appendReasoningToMessage(widgetId, currentMsgId, event.text);
340-
} else if (event.type === "text-delta") {
341-
setReasoningStreaming(widgetId, false);
342-
hasEmittedText = true;
343-
fullText += event.text;
344-
updateAssistantMessage(widgetId, currentMsgId, fullText);
345-
} else if (event.type === "widget-file") {
346-
if (event.path && event.content) {
347-
setWidgetFile(widgetId, event.path, event.content);
348-
}
349-
} else if (event.type === "widget-code") {
350-
if (event.code) {
351-
setWidgetCode(widgetId, event.code);
352-
showAction("Building widget…");
353-
setTimeout(() => bumpIframeVersion(widgetId), 15000);
339+
while (true) {
340+
const { done, value } = await reader.read();
341+
if (done) {
342+
streamCompleted = true;
343+
break;
344+
}
345+
346+
buffer += decoder.decode(value, { stream: true });
347+
const parts = buffer.split("\n\n");
348+
buffer = parts.pop() ?? "";
349+
350+
for (const part of parts) {
351+
const line = part.trim();
352+
if (!line.startsWith("data:")) continue;
353+
const payload = line.slice(5).trim();
354+
if (payload === "[DONE]") {
355+
streamCompleted = true;
356+
continue;
354357
}
355-
} else if (event.type === "tool-call") {
356-
let action = "";
357-
if (event.toolName === "writeFile") {
358-
const filePath = event.args?.path ?? "";
359-
action =
360-
filePath === "src/App.tsx"
361-
? "Writing widget code"
362-
: `Writing ${filePath}`;
363-
} else if (event.toolName === "readFile") {
364-
action = `Reading ${event.args?.path ?? "file"}`;
365-
} else if (event.toolName === "bash") {
366-
const cmd = String(event.args?.command ?? "");
367-
action = cmd.length > 40 ? `Running: ${cmd.slice(0, 40)}…` : `Running: ${cmd}`;
368-
} else if (event.toolName === "listDashboardWidgets") {
369-
action = "Checking dashboard widgets";
370-
} else if (event.toolName === "readWidgetCode") {
371-
action = `Reading ${event.args?.targetWidgetId ?? "sibling"} code`;
372-
} else if (event.toolName === "web_search") {
373-
action = event.args?.query
374-
? `Searching "${event.args.query}"`
375-
: "Searching the web";
376-
} else {
377-
action = `Using ${event.toolName}`;
358+
359+
try {
360+
const event = JSON.parse(payload);
361+
if (event.type === "reasoning-delta") {
362+
if (hasEmittedText) {
363+
startNewAssistantMessage();
364+
}
365+
setReasoningStreaming(widgetId, true);
366+
appendReasoningToMessage(widgetId, currentMsgId, event.text);
367+
} else if (event.type === "text-delta") {
368+
setReasoningStreaming(widgetId, false);
369+
hasEmittedText = true;
370+
fullText += event.text;
371+
updateAssistantMessage(widgetId, currentMsgId, fullText);
372+
} else if (event.type === "widget-file") {
373+
if (event.path && event.content) {
374+
setWidgetFile(widgetId, event.path, event.content);
375+
}
376+
} else if (event.type === "widget-code") {
377+
if (event.code) {
378+
setWidgetCode(widgetId, event.code);
379+
showAction("Building widget…");
380+
setTimeout(() => bumpIframeVersion(widgetId), 15000);
381+
}
382+
} else if (event.type === "tool-call") {
383+
let action = "";
384+
if (event.toolName === "writeFile") {
385+
const filePath = event.args?.path ?? "";
386+
action =
387+
filePath === "src/App.tsx"
388+
? "Writing widget code"
389+
: `Writing ${filePath}`;
390+
} else if (event.toolName === "readFile") {
391+
action = `Reading ${event.args?.path ?? "file"}`;
392+
} else if (event.toolName === "bash") {
393+
const cmd = String(event.args?.command ?? "");
394+
action = cmd.length > 40 ? `Running: ${cmd.slice(0, 40)}…` : `Running: ${cmd}`;
395+
} else if (event.toolName === "listDashboardWidgets") {
396+
action = "Checking dashboard widgets";
397+
} else if (event.toolName === "readWidgetCode") {
398+
action = `Reading ${event.args?.targetWidgetId ?? "sibling"} code`;
399+
} else if (event.toolName === "web_search") {
400+
action = event.args?.query
401+
? `Searching "${event.args.query}"`
402+
: "Searching the web";
403+
} else {
404+
action = `Using ${event.toolName}`;
405+
}
406+
if (action) showAction(action);
407+
} else if (event.type === "tool-result") {
408+
clearActionWithMinimumVisibility();
409+
} else if (event.type === "abort") {
410+
updateAssistantMessage(
411+
widgetId,
412+
currentMsgId,
413+
fullText || "[Interrupted]"
414+
);
415+
} else if (event.type === "error") {
416+
updateAssistantMessage(
417+
widgetId,
418+
currentMsgId,
419+
`Error: ${event.error}`
420+
);
421+
}
422+
} catch {
423+
// skip malformed chunks
378424
}
379-
if (action) showAction(action);
380-
} else if (event.type === "tool-result") {
381-
clearActionWithMinimumVisibility();
382-
} else if (event.type === "abort") {
383-
updateAssistantMessage(
384-
widgetId,
385-
currentMsgId,
386-
fullText || "[Interrupted]"
387-
);
388-
} else if (event.type === "error") {
389-
updateAssistantMessage(
390-
widgetId,
391-
currentMsgId,
392-
`Error: ${event.error}`
393-
);
394425
}
395-
} catch {
396-
// skip malformed chunks
426+
}
427+
428+
if (streamCompleted) return;
429+
} catch (err) {
430+
if ((err as Error).name === "AbortError") {
431+
updateAssistantMessage(widgetId, currentMsgId, fullText || "[Interrupted]");
432+
return;
433+
}
434+
if (!isNetworkError(err) || attempt >= MAX_RETRIES) {
435+
const friendly = isNetworkError(err)
436+
? "Connection lost — please check your network and try again."
437+
: `Error: ${String(err)}`;
438+
updateAssistantMessage(widgetId, currentMsgId, fullText ? `${fullText}\n\n${friendly}` : friendly);
439+
return;
397440
}
398441
}
399442
}
400-
} catch (err) {
401-
if ((err as Error).name === "AbortError") {
402-
updateAssistantMessage(widgetId, currentMsgId, fullText || "[Interrupted]");
403-
} else {
404-
updateAssistantMessage(widgetId, currentMsgId, `Error: ${String(err)}`);
443+
444+
if (!controller.signal.aborted) {
445+
updateAssistantMessage(
446+
widgetId,
447+
currentMsgId,
448+
fullText
449+
? `${fullText}\n\nConnection lost after multiple retries — please try again.`
450+
: "Connection lost after multiple retries — please try again.",
451+
);
405452
}
406453
} finally {
407454
abortControllers.delete(widgetId);

src/lib/create-model.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,35 @@ import type { CustomApiConfig } from "@/store/settings-store";
1919
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2020
type ProviderFactory = (opts?: { apiKey?: string; baseURL?: string }) => (modelId: string) => any;
2121

22+
const REQUEST_TIMEOUT_MS = 800_000; // ~13 min — match maxDuration on API routes
23+
24+
function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
25+
if (init?.signal) return fetch(input, init);
26+
return fetch(input, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
27+
}
28+
29+
interface ProviderOpts { apiKey?: string; baseURL?: string }
30+
31+
function withTimeout(opts?: ProviderOpts): ProviderOpts & { fetch: typeof fetch } {
32+
return { ...opts, fetch: fetchWithTimeout as typeof fetch };
33+
}
34+
2235
const providers: Record<string, ProviderFactory> = {
23-
anthropic: (opts) => createAnthropic(opts),
24-
openai: (opts) => createOpenAI(opts),
25-
google: (opts) => createGoogleGenerativeAI(opts),
26-
xai: (opts) => createXai(opts),
27-
mistral: (opts) => createMistral(opts),
28-
groq: (opts) => createGroq(opts),
29-
deepseek: (opts) => createDeepSeek(opts),
30-
perplexity: (opts) => createPerplexity(opts),
31-
cohere: (opts) => createCohere(opts),
32-
cerebras: (opts) => createCerebras(opts),
33-
togetherai: (opts) => createTogetherAI(opts),
34-
fireworks: (opts) => createFireworks(opts),
35-
moonshotai: (opts) => createMoonshotAI(opts),
36-
alibaba: (opts) => createAlibaba(opts),
37-
deepinfra: (opts) => createDeepInfra(opts),
36+
anthropic: (opts) => createAnthropic(withTimeout(opts)),
37+
openai: (opts) => createOpenAI(withTimeout(opts)),
38+
google: (opts) => createGoogleGenerativeAI(withTimeout(opts)),
39+
xai: (opts) => createXai(withTimeout(opts)),
40+
mistral: (opts) => createMistral(withTimeout(opts)),
41+
groq: (opts) => createGroq(withTimeout(opts)),
42+
deepseek: (opts) => createDeepSeek(withTimeout(opts)),
43+
perplexity: (opts) => createPerplexity(withTimeout(opts)),
44+
cohere: (opts) => createCohere(withTimeout(opts)),
45+
cerebras: (opts) => createCerebras(withTimeout(opts)),
46+
togetherai: (opts) => createTogetherAI(withTimeout(opts)),
47+
fireworks: (opts) => createFireworks(withTimeout(opts)),
48+
moonshotai: (opts) => createMoonshotAI(withTimeout(opts)),
49+
alibaba: (opts) => createAlibaba(withTimeout(opts)),
50+
deepinfra: (opts) => createDeepInfra(withTimeout(opts)),
3851
};
3952

4053
const CUSTOM_PROVIDER_PREFIX = "custom:";

0 commit comments

Comments
 (0)