Files
gitmost/apps/server/src/integrations/ai/ai-error.util.spec.ts
T
claude_code f543e79c3e fix(ai-embedding): abort bulk reindex on fatal provider errors
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>
2026-06-22 03:46:17 +03:00

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);
});
});