fix(ai-chat): surface dropped-stream errors clearly + log client disconnects
A mid-stream connection drop showed a generic "Something went wrong / Load
failed" banner and left no server-side trace.
- error-message: classify the browsers' own fetch-failure strings ("Load
failed" on WebKit, "Failed to fetch" on Chrome, "NetworkError" on Firefox)
as a lost connection, so the banner names the cause instead of the generic
heading.
- ai-chat.controller: log a warning in the request close handler when the
client disconnects before completion, so a drop that reaches the app (e.g. a
reverse proxy cutting the SSE) is visible in the server logs before the abort.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -85,8 +85,12 @@ function classifyProviderError(msg: string): ErrorCategory | null {
|
||||
// Connection dropped / provider unreachable. ECONNRESET is the production case:
|
||||
// the LLM socket was reset mid-stream. "terminated" is scoped to a connection/
|
||||
// stream context so it does not match benign "... was terminated" messages.
|
||||
// The browser's own fetch-failure messages also land here because they mean the
|
||||
// SSE stream to /api/ai-chat/stream dropped mid-answer (e.g. a reverse proxy cut
|
||||
// it): WebKit/Safari says "Load failed", Chrome "Failed to fetch", Firefox
|
||||
// "NetworkError when attempting to fetch resource".
|
||||
if (
|
||||
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
|
||||
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|failed to fetch|load failed|networkerror|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
|
||||
head,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -161,7 +161,16 @@ export class AiChatController {
|
||||
// cannot simply remove it once `stream()` returns).
|
||||
const controller = new AbortController();
|
||||
const onClose = (): void => {
|
||||
if (!res.raw.writableEnded) controller.abort();
|
||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||
// so log it here before aborting the agent loop.
|
||||
if (!res.raw.writableEnded) {
|
||||
this.logger.warn(
|
||||
'AI chat stream: client disconnected before completion; aborting turn',
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
req.raw.once('close', onClose);
|
||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||
|
||||
Reference in New Issue
Block a user