f543e79c3e
reindexWorkspace isolated every per-page failure, so an invalid/missing API key (401 "User not found") made all pages fail identically while the batch kept issuing hundreds of doomed requests against the provider. Add isFatalProviderError() (401/403 auth, 402 billing) and abort the whole batch on such errors; 429 rate-limit and embedding timeouts stay per-page isolated. Adds unit tests for the predicate and a regression test for the abort/iterate control flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
187 lines
7.3 KiB
TypeScript
187 lines
7.3 KiB
TypeScript
import { describeProviderError, isFatalProviderError } from './ai-error.util';
|
|
|
|
/**
|
|
* Unit tests for describeProviderError: the shared formatter used both for the
|
|
* server log line and for the error text streamed back to the client. This
|
|
* pins the behaviour, including the one behaviour change introduced when the
|
|
* two inline formatters were unified: a truncated, single-line snippet of the
|
|
* provider `responseBody`/`text` is appended (so a misconfigured endpoint's
|
|
* HTML error page is diagnosable). The util guarantees the API key is never in
|
|
* the response body, so this is safe to surface.
|
|
*/
|
|
describe('describeProviderError', () => {
|
|
it('uses the fallback for a null/empty/undefined error', () => {
|
|
expect(describeProviderError(null, 'AI stream error')).toBe(
|
|
'AI stream error',
|
|
);
|
|
expect(describeProviderError('', 'AI stream error')).toBe('AI stream error');
|
|
expect(describeProviderError(undefined)).toBe('Unknown error');
|
|
});
|
|
|
|
it('returns a non-empty plain string error as-is', () => {
|
|
expect(describeProviderError('boom')).toBe('boom');
|
|
});
|
|
|
|
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: 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', () => {
|
|
expect(describeProviderError({ message: 'nope' })).toBe('nope');
|
|
});
|
|
|
|
it('appends a whitespace-collapsed response body snippet', () => {
|
|
const out = describeProviderError({
|
|
statusCode: 502,
|
|
message: 'Bad Gateway',
|
|
responseBody: '<html>\n <body>upstream error</body>\n</html>',
|
|
});
|
|
expect(out.startsWith('502: Bad Gateway | response body: ')).toBe(true);
|
|
// Newlines and runs of spaces are collapsed to single spaces.
|
|
expect(out).toContain('<html> <body>upstream error</body> </html>');
|
|
});
|
|
|
|
it('reads `text` when responseBody is absent', () => {
|
|
expect(describeProviderError({ message: 'e', text: 'body-text' })).toBe(
|
|
'e | response body: body-text',
|
|
);
|
|
});
|
|
|
|
it('truncates a long body to 300 chars + ellipsis', () => {
|
|
const out = describeProviderError({
|
|
message: 'e',
|
|
responseBody: 'x'.repeat(500),
|
|
});
|
|
expect(out).toContain('…');
|
|
// 'e | response body: ' + 300 chars + '…'
|
|
expect(out.length).toBeLessThan('e | response body: '.length + 305);
|
|
});
|
|
|
|
it('uses the fallback for a numeric or boolean (non-object, non-string) error', () => {
|
|
// typeof number / boolean is neither 'object' nor a non-empty 'string', so
|
|
// the early branch returns the fallback verbatim.
|
|
expect(describeProviderError(500, 'AI stream error')).toBe('AI stream error');
|
|
expect(describeProviderError(0, 'AI stream error')).toBe('AI stream error');
|
|
expect(describeProviderError(true)).toBe('Unknown error');
|
|
expect(describeProviderError(false, 'fb')).toBe('fb');
|
|
});
|
|
|
|
it('statusCode present but message undefined => "<code>:" with no trailing space', () => {
|
|
// `${code}: ${undefined ?? ''}`.trim() collapses to just "<code>:".
|
|
expect(describeProviderError({ statusCode: 503 })).toBe('503:');
|
|
// The trailing space after the colon is trimmed away.
|
|
expect(describeProviderError({ statusCode: 503 }).endsWith(': ')).toBe(false);
|
|
});
|
|
|
|
it('object with neither message nor statusCode nor body => fallback', () => {
|
|
expect(describeProviderError({}, 'AI stream error')).toBe('AI stream error');
|
|
// An object carrying only unrelated keys is still treated as message-less.
|
|
expect(describeProviderError({ foo: 'bar' } as never)).toBe('Unknown error');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Unit tests for isFatalProviderError: the predicate that decides whether a
|
|
* provider error should abort an ENTIRE batch (bulk reindex) rather than be
|
|
* isolated per item. Authentication (401/403) and billing (402) recur
|
|
* identically on every request and are fatal; a 429 rate limit is transient and
|
|
* intentionally NOT fatal (handled by per-item isolation / backoff). Anything
|
|
* without a recognised numeric statusCode is non-fatal.
|
|
*/
|
|
describe('isFatalProviderError', () => {
|
|
it('returns true for authentication errors (401/403)', () => {
|
|
expect(isFatalProviderError({ statusCode: 401, message: 'User not found' })).toBe(
|
|
true,
|
|
);
|
|
expect(isFatalProviderError({ statusCode: 403, message: 'Forbidden' })).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('returns true for a billing error (402)', () => {
|
|
expect(
|
|
isFatalProviderError({ statusCode: 402, message: 'Payment Required' }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('returns false for a 429 rate limit (transient, intentionally non-fatal)', () => {
|
|
expect(
|
|
isFatalProviderError({ statusCode: 429, message: 'Too Many Requests' }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false for a 500 server error', () => {
|
|
expect(isFatalProviderError({ statusCode: 500, message: 'Server error' })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('returns false for an embedding timeout (plain Error, no statusCode)', () => {
|
|
expect(
|
|
isFatalProviderError(new Error('Embedding request timed out after 60000ms')),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false for non-object errors (null/undefined/string/number)', () => {
|
|
expect(isFatalProviderError(null)).toBe(false);
|
|
expect(isFatalProviderError(undefined)).toBe(false);
|
|
expect(isFatalProviderError('boom')).toBe(false);
|
|
// A bare numeric 401 is NOT an object carrying a statusCode field.
|
|
expect(isFatalProviderError(401)).toBe(false);
|
|
});
|
|
|
|
it('returns false for an object without a statusCode', () => {
|
|
expect(isFatalProviderError({})).toBe(false);
|
|
});
|
|
});
|