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>
This commit is contained in:
claude_code
2026-06-22 03:46:17 +03:00
parent b60190ff1e
commit f543e79c3e
4 changed files with 167 additions and 4 deletions
@@ -1,4 +1,4 @@
import { describeProviderError } from './ai-error.util';
import { describeProviderError, isFatalProviderError } from './ai-error.util';
/**
* Unit tests for describeProviderError: the shared formatter used both for the
@@ -129,3 +129,58 @@ describe('describeProviderError', () => {
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);
});
});
@@ -47,6 +47,20 @@ export function describeProviderError(
return label ? `${label}${detail}` : detail;
}
/**
* Whether a provider error is FATAL for an ENTIRE batch operation rather than
* specific to one item. Authentication (401/403 — invalid or missing API key)
* and billing (402 — insufficient credits/quota) failures recur identically on
* every subsequent request, so a bulk reindex should abort immediately instead
* of issuing hundreds of doomed calls. A 429 rate limit is intentionally NOT
* fatal: it is transient and better handled by per-item isolation / backoff.
*/
export function isFatalProviderError(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const status = (err as { statusCode?: number }).statusCode;
return status === 401 || status === 403 || status === 402;
}
// 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.