Skip to content

Commit c3a43f4

Browse files
Yigtwxxclaude
andcommitted
fix(error-classifier): rank keyword auth signals above offline patterns
A rejected token is often reported alongside transport wording, such as an expired-token payload whose description reads "Access token expired; connection timed out". Placing the offline patterns ahead of every auth signal therefore narrowed OAuth promotion well beyond the embedded-digit case this branch targets. Keyword signals (unauthorized, invalid_token, forbidden) are unambiguous, so they now run before the offline check. Only the bare 401 numeral, which is genuinely ambiguous in ports, durations, and request ids, stays subordinate to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnrQ4wMJsicJUpQsarAHkw
1 parent 9a0a9f7 commit c3a43f4

2 files changed

Lines changed: 34 additions & 8 deletions

File tree

src/error-classifier.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ export interface ConnectionIssue {
1111
}
1212

1313
const AUTH_STATUSES = new Set([401, 403]);
14-
// 401 only counts as an auth signal when it stands alone as a token, so ports, durations,
15-
// hostnames, and request ids ("127.0.0.1:14012", "4010ms", "abc401def", "request_401_id")
16-
// are not read as one. The keywords stay substring matches because OAuth error codes embed
17-
// them with underscores ("unauthorized_client", "invalid_token_hint").
18-
const AUTH_TOKEN_PATTERNS = [/(?<![0-9a-z_])401(?![0-9a-z_])/i, /unauthorized/, /invalid_token/, /forbidden/];
14+
// Keywords are unambiguous auth signals, so they outrank offline patterns: a rejected token is
15+
// often reported alongside transport wording ("Access token expired; connection timed out").
16+
// They stay substring matches because OAuth error codes embed them with underscores
17+
// ("unauthorized_client", "invalid_token_hint").
18+
const KEYWORD_AUTH_PATTERNS = [/unauthorized/, /invalid_token/, /forbidden/];
19+
// A bare 401 is ambiguous, so it only applies once transport failures are ruled out, and only
20+
// when it stands alone as a token. Ports, durations, hostnames, and request ids
21+
// ("127.0.0.1:14012", "4010ms", "abc401def", "request_401_id") are therefore not read as one.
22+
const NUMERIC_AUTH_PATTERNS = [/(?<![0-9a-z_])401(?![0-9a-z_])/i];
1923
const OFFLINE_PATTERNS = [
2024
'fetch failed',
2125
'econnrefused',
@@ -63,10 +67,13 @@ export function analyzeConnectionError(error: unknown): ConnectionIssue {
6367
return { kind: 'http', rawMessage, statusCode };
6468
}
6569
}
70+
if (matchesAny(KEYWORD_AUTH_PATTERNS, normalized)) {
71+
return { kind: 'auth', rawMessage, statusCode };
72+
}
6673
if (OFFLINE_PATTERNS.some((pattern) => normalized.includes(pattern))) {
6774
return { kind: 'offline', rawMessage };
6875
}
69-
if (containsAuthToken(normalized)) {
76+
if (matchesAny(NUMERIC_AUTH_PATTERNS, normalized)) {
7077
return { kind: 'auth', rawMessage, statusCode };
7178
}
7279
return { kind: 'other', rawMessage };
@@ -136,8 +143,8 @@ function extractStatusCode(message: string): number | undefined {
136143
return undefined;
137144
}
138145

139-
function containsAuthToken(normalizedMessage: string): boolean {
140-
return AUTH_TOKEN_PATTERNS.some((pattern) => pattern.test(normalizedMessage));
146+
function matchesAny(patterns: readonly RegExp[], normalizedMessage: string): boolean {
147+
return patterns.some((pattern) => pattern.test(normalizedMessage));
141148
}
142149

143150
function extractStdioExit(message: string): { stdioExitCode?: number; stdioSignal?: string } | undefined {

tests/error-classifier.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,25 @@ describe('analyzeConnectionError', () => {
116116
});
117117
});
118118

119+
describe('keyword auth signals outrank offline transport patterns', () => {
120+
it('classifies an expired-token payload as auth even when it mentions a timeout', () => {
121+
const issue = analyzeConnectionError(
122+
new Error('{"error":"invalid_token","error_description":"Access token expired; connection timed out"}')
123+
);
124+
expect(issue.kind).toBe('auth');
125+
});
126+
127+
it('classifies an unauthorized stream disconnect as auth rather than offline', () => {
128+
const issue = analyzeConnectionError(new Error('SSE stream disconnected: Unauthorized, connection closed'));
129+
expect(issue.kind).toBe('auth');
130+
});
131+
132+
it('keeps a bare 401 subordinate to offline patterns', () => {
133+
const issue = analyzeConnectionError(new Error('fetch failed: connect ECONNREFUSED 127.0.0.1:401'));
134+
expect(issue.kind).toBe('offline');
135+
});
136+
});
137+
119138
describe('known status codes take precedence over message keywords', () => {
120139
it('classifies code=404 as http when the message also mentions unauthorized', () => {
121140
const err = Object.assign(new Error('Not Found: /unauthorized'), {

0 commit comments

Comments
 (0)