fix(ai): classify AI provider error status in logs and UI

Provider auth failures were logged with the provider's opaque message only
(e.g. OpenRouter returns "401: User not found." for a bad/missing API key),
which reads like a missing wiki user rather than a credentials problem.

describeProviderError now prepends a clear, human-readable English label for
a small set of well-known HTTP statuses while keeping the original detail
(status + provider message + truncated response-body snippet):
  - 401/403 -> authentication failed (invalid or missing API key)
  - 402     -> insufficient credits or quota
  - 429     -> rate limit exceeded
Other statuses and status-less errors are formatted exactly as before. The
label is a static string and never contains the API key. Benefits every
caller (embedding processor, indexer, AI "Test endpoint" UI) at once.

Tests: switch the plain status+message case to a non-classified status (500);
add 401/403/402/429 cases; keep 502/503 as regression guards for the
unchanged path.
This commit is contained in:
claude_code
2026-06-21 19:55:45 +03:00
parent 4f8015b342
commit 7171dfbdf0
2 changed files with 79 additions and 5 deletions

View File

@@ -22,10 +22,58 @@ describe('describeProviderError', () => {
expect(describeProviderError('boom')).toBe('boom');
});
it('formats statusCode + message', () => {
it('formats statusCode + message (non-classified status)', () => {
// 500 is not in the well-known status map, so no label is prepended and the
// plain "<status>: <message>" path is exercised.
expect(
describeProviderError({ statusCode: 401, message: 'Unauthorized' }),
).toBe('401: Unauthorized');
describeProviderError({ statusCode: 500, message: 'Server error' }),
).toBe('500: Server error');
});
it('prepends an auth label for 401 (the real cause behind "User not found.")', () => {
const out = describeProviderError({
statusCode: 401,
message: 'User not found.',
});
expect(out).toBe(
'AI provider authentication failed (invalid or missing API key) — 401: User not found.',
);
// The provider status is still present after the label.
expect(out).toContain('401:');
// With a response body, the snippet is appended AFTER the label/detail.
const withBody = describeProviderError({
statusCode: 401,
message: 'User not found.',
responseBody: '{"error":{"message":"User not found.","code":401}}',
});
expect(
withBody.startsWith(
'AI provider authentication failed (invalid or missing API key) — 401: User not found. | response body: ',
),
).toBe(true);
expect(withBody).toContain('| response body:');
});
it('prepends the same auth label for 403', () => {
expect(
describeProviderError({ statusCode: 403, message: 'Forbidden' }),
).toBe(
'AI provider authentication failed (invalid or missing API key) — 403: Forbidden',
);
});
it('prepends a billing label for 402', () => {
expect(
describeProviderError({ statusCode: 402, message: 'Payment Required' }),
).toBe(
'AI provider rejected the request: insufficient credits or quota — 402: Payment Required',
);
});
it('prepends a rate-limit label for 429', () => {
expect(
describeProviderError({ statusCode: 429, message: 'Too Many Requests' }),
).toBe('AI provider rate limit exceeded — 429: Too Many Requests');
});
it('falls back to message when there is no statusCode', () => {

View File

@@ -10,6 +10,12 @@
* None of these fields contain the API key (it is sent as an Authorization
* header and never echoed in the response body), so this is safe to log/return.
*
* A small set of well-known HTTP statuses (auth / billing / rate limit) are
* classified and a clear, human-readable English label is prepended, so the
* log/UI states the real cause instead of only the provider's opaque message
* (e.g. a 401 "User not found." is really a bad/missing API key). The label is
* a static string and never contains the API key.
*
* `fallback` is used when the error carries no usable message (e.g. a bare
* object); defaults to 'Unknown error'.
*/
@@ -31,9 +37,29 @@ export function describeProviderError(
? `${e.statusCode}: ${e.message ?? ''}`.trim()
: (e.message ?? fallback);
const body = (e.responseBody ?? e.text ?? '').trim();
if (!body) return base;
// Collapse whitespace so a multi-line HTML body stays on one log line.
const oneLine = body.replace(/\s+/g, ' ');
const snippet = oneLine.length > 300 ? `${oneLine.slice(0, 300)}` : oneLine;
return `${base} | response body: ${snippet}`;
const detail = body ? `${base} | response body: ${snippet}` : base;
// Classify well-known HTTP statuses so the log/UI states the real problem
// (auth / billing / rate limit) instead of only the provider's opaque message.
const label = classifyStatus(e.statusCode);
return label ? `${label}${detail}` : detail;
}
// Map a small set of well-known provider HTTP statuses to a clear,
// human-readable cause. Returns null for anything else so the existing
// "<status>: <message> | response body: …" output is preserved unchanged.
function classifyStatus(statusCode?: number): string | null {
switch (statusCode) {
case 401:
case 403:
return 'AI provider authentication failed (invalid or missing API key)';
case 402:
return 'AI provider rejected the request: insufficient credits or quota';
case 429:
return 'AI provider rate limit exceeded';
default:
return null;
}
}