Skip to content

Commit 7232b95

Browse files
committed
fix(chat): tighten error status parsing to HTTP-shaped tokens + connection-before-text; dedup isAbortError/messages
1 parent f2ce176 commit 7232b95

4 files changed

Lines changed: 77 additions & 20 deletions

File tree

libs/ag-ui/src/lib/to-agent.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { computed, signal, type Signal } from '@angular/core';
33
import { Subject } from 'rxjs';
44
import type { AbstractAgent } from '@ag-ui/client';
5-
import { toAgentError, type AgentError } from '@threadplane/chat';
5+
import { toAgentError, isAbortError, type AgentError } from '@threadplane/chat';
66
import type {
77
Agent, Message, AgentStatus, ToolCall, AgentEvent,
88
AgentInterrupt,
@@ -109,11 +109,6 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag
109109
// duplicating the user message. Set at the top of submit()'s message path.
110110
let lastInput: AgentSubmitInput | undefined;
111111

112-
function isAbortError(error: unknown): boolean {
113-
return error instanceof Error
114-
&& (error.name === 'AbortError' || /abort/i.test(error.message));
115-
}
116-
117112
/** Settles the store as idle for stop()-induced failures; returns true if handled. */
118113
function settleIfAborted(error: unknown): boolean {
119114
// If we already settled this abort (duplicate delivery — e.g. RUN_ERROR

libs/chat/src/lib/agent/to-agent-error.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,27 @@ describe('toAgentError', () => {
4141
const e = toAgentError({ status: 503, message: 'Service Unavailable' });
4242
expect(e.kind).toBe('server'); expect(e.status).toBe(503); expect(e.retryable).toBe(true);
4343
});
44+
45+
// NEW: bare 3-digit tokens in model names must NOT yield a bogus status
46+
it('does NOT extract status from a bare 3-digit model version string', () => {
47+
const e = toAgentError(new Error('model gpt-500 is not available'));
48+
expect(e.kind).toBe('server');
49+
expect(e.retryable).toBe(true);
50+
expect(e.status).toBeUndefined();
51+
});
52+
53+
// NEW: connection detection must fire BEFORE loose text parsing
54+
it('classifies "Failed to fetch (502 upstream)" as connection, not server', () => {
55+
const e = toAgentError(new TypeError('Failed to fetch (502 upstream)'));
56+
expect(e.kind).toBe('connection');
57+
expect(e.retryable).toBe(true);
58+
});
59+
60+
// NEW: structured cause.status path
61+
it('reads structured status via cause.status', () => {
62+
const e = toAgentError({ cause: { status: 403 } });
63+
expect(e.kind).toBe('auth');
64+
expect(e.status).toBe(403);
65+
expect(e.retryable).toBe(false);
66+
});
4467
});

libs/chat/src/lib/agent/to-agent-error.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@ export function isAbortError(raw: unknown): boolean {
66
return raw instanceof Error && (raw.name === 'AbortError' || /\babort/i.test(raw.message));
77
}
88

9-
function readStatus(raw: unknown): number | undefined {
9+
/**
10+
* Reads a numeric status from `raw.status` or `raw.cause.status` only.
11+
* No text parsing — structured fields only.
12+
*/
13+
function structuredStatus(raw: unknown): number | undefined {
1014
const obj = raw as { status?: unknown; cause?: { status?: unknown } } | null;
1115
const direct = typeof obj?.status === 'number' ? obj.status : undefined;
1216
const viaCause = typeof obj?.cause?.status === 'number' ? obj!.cause!.status : undefined;
1317
if (direct !== undefined) return direct;
1418
if (viaCause !== undefined) return viaCause;
15-
const msg = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : '';
16-
const m = /\b(\d{3})\b/.exec(msg);
17-
return m ? Number(m[1]) : undefined;
19+
return undefined;
1820
}
1921

2022
function isConnectionError(raw: unknown): boolean {
@@ -24,23 +26,60 @@ function isConnectionError(raw: unknown): boolean {
2426
);
2527
}
2628

29+
/**
30+
* Extracts an HTTP status code from a message string, but ONLY when the token
31+
* is unambiguously HTTP-shaped:
32+
* - `HTTP/502`, `HTTP 404`, `HTTP404`
33+
* - `status: 503`, `status=503`, `code: 404`
34+
*
35+
* Bare 3-digit numbers (e.g. model version strings like "gpt-500") are NOT matched.
36+
*/
37+
function httpStatusFromMessage(raw: unknown): number | undefined {
38+
const msg =
39+
raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : '';
40+
if (!msg) return undefined;
41+
42+
// "HTTP 500", "HTTP/500", "HTTP500"
43+
const httpToken = /\bHTTP[ /]?(\d{3})\b/i.exec(msg);
44+
if (httpToken) return Number(httpToken[1]);
45+
46+
// "status: 503", "status=503", "status 503" (up to 4 non-digit chars between keyword and digits)
47+
// Also matches "code: 404", "code=404", etc.
48+
const prefixed = /\b(?:status|code)\b\D{0,4}(\d{3})\b/i.exec(msg);
49+
if (prefixed) return Number(prefixed[1]);
50+
51+
return undefined;
52+
}
53+
2754
function make(kind: AgentErrorKind, retryable: boolean, raw: unknown, status?: number, message?: string): AgentError {
2855
return new AgentError({ kind, retryable, status, cause: raw, message: message ?? AGENT_ERROR_MESSAGES[kind] });
2956
}
3057

58+
function classifyByStatus(status: number, raw: unknown): AgentError {
59+
if (status === 401 || status === 403) return make('auth', false, raw, status);
60+
if (status >= 500) return make('server', true, raw, status);
61+
if (status >= 400) return make('server', false, raw, status, `The request was rejected (HTTP ${status}).`);
62+
// Stray 2xx/3xx from a status field — treat as unknown transient failure, no status.
63+
return make('server', true, raw, undefined, 'Something went wrong. You can try again.');
64+
}
65+
3166
/** Classify any raw error into a structured {@link AgentError}. Idempotent. */
3267
export function toAgentError(raw: unknown): AgentError {
3368
if (raw instanceof AgentError) return raw;
3469
if (isAbortError(raw)) return make('aborted', false, raw);
3570

36-
const status = readStatus(raw);
37-
if (status !== undefined) {
38-
if (status === 401 || status === 403) return make('auth', false, raw, status);
39-
if (status >= 500) return make('server', true, raw, status);
40-
if (status >= 400) return make('server', false, raw, status, `The request was rejected (HTTP ${status}).`);
41-
}
71+
// 1. Structured status (authoritative): raw.status or raw.cause.status.
72+
const structured = structuredStatus(raw);
73+
if (structured !== undefined) return classifyByStatus(structured, raw);
74+
75+
// 2. Network/connection markers are definitive — before any loose text parsing.
4276
if (isConnectionError(raw)) return make('connection', true, raw);
4377

78+
// 3. Best-effort: only an HTTP-shaped status token in the message counts.
79+
const httpStatus = httpStatusFromMessage(raw);
80+
if (httpStatus !== undefined) return classifyByStatus(httpStatus, raw);
81+
82+
// 4. Fallback: unknown failure, assume transient.
4483
const msg = raw instanceof Error && raw.message ? raw.message : 'Something went wrong. You can try again.';
45-
return make('server', true, raw, status, msg);
84+
return make('server', true, raw, undefined, msg);
4685
}

libs/langgraph/src/lib/internals/stream-manager.bridge.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
AgentRuntimeTelemetryProperties,
2121
AgentRuntimeTelemetrySink,
2222
} from '@threadplane/chat';
23-
import { AgentError, toAgentError, isAbortError } from '@threadplane/chat';
23+
import { AgentError, AGENT_ERROR_MESSAGES, toAgentError, isAbortError } from '@threadplane/chat';
2424
import {
2525
SubagentTracker,
2626
TrackedSubagent,
@@ -450,8 +450,8 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
450450
// A non-user-requested abort: interrupted if a stream had started, else a
451451
// connect-phase failure. Never "aborted" (that's reserved for user stop).
452452
const e = streamingStarted
453-
? new AgentError({ kind: 'interrupted', message: 'The response was interrupted. Try again.', retryable: true, cause: err })
454-
: new AgentError({ kind: 'connection', message: "Can't reach the server. Check your connection and try again.", retryable: true, cause: err });
453+
? new AgentError({ kind: 'interrupted', message: AGENT_ERROR_MESSAGES.interrupted, retryable: true, cause: err })
454+
: new AgentError({ kind: 'connection', message: AGENT_ERROR_MESSAGES.connection, retryable: true, cause: err });
455455
subjects.error$.next(e);
456456
subjects.status$.next(ResourceStatus.Error);
457457
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {

0 commit comments

Comments
 (0)