diff --git a/packages/agent-toolkit/src/errors.ts b/packages/agent-toolkit/src/errors.ts index 49aceca12d..063351f44a 100644 --- a/packages/agent-toolkit/src/errors.ts +++ b/packages/agent-toolkit/src/errors.ts @@ -2,14 +2,23 @@ export class BusinessError extends Error { // INTERNAL USAGES public readonly isBusinessError = true; - public baseBusinessErrorName: string; + public readonly baseBusinessErrorName: string; + public readonly httpCode: number; public readonly data: Record | undefined; - constructor(message?: string, data?: Record, name?: string) { + constructor( + message?: string, + data?: Record, + name?: string, + httpCode = 422, + baseBusinessErrorName?: string, + ) { super(message); this.name = name ?? this.constructor.name; this.data = data; + this.httpCode = httpCode; + this.baseBusinessErrorName = baseBusinessErrorName ?? this.constructor.name; } /** @@ -27,31 +36,36 @@ export class BusinessError extends Error { export class ValidationError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); - this.baseBusinessErrorName = 'ValidationError'; + super(message, data, name, 400, 'ValidationError'); } } export class BadRequestError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); - this.baseBusinessErrorName = 'BadRequestError'; + super(message, data, name, 400, 'BadRequestError'); } } export class UnprocessableError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); - this.baseBusinessErrorName = 'UnprocessableError'; + super(message, data, name, 422, 'UnprocessableError'); } } export class ForbiddenError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); - this.baseBusinessErrorName = 'ForbiddenError'; + super(message, data, name, 403, 'ForbiddenError'); } } export class NotFoundError extends BusinessError { constructor(message?: string, data?: Record, name?: string) { - super(message, data, name); - this.baseBusinessErrorName = 'NotFoundError'; + super(message, data, name, 404, 'NotFoundError'); + } +} +export class UnauthorizedError extends BusinessError { + constructor(message?: string, data?: Record, name?: string) { + super(message, data, name, 401, 'UnauthorizedError'); + } +} +export class TooManyRequestsError extends BusinessError { + constructor(message?: string, data?: Record, name?: string) { + super(message, data, name, 429, 'TooManyRequestsError'); } } diff --git a/packages/agent-toolkit/test/errors.test.ts b/packages/agent-toolkit/test/errors.test.ts index 1188e224bc..a384ccfc31 100644 --- a/packages/agent-toolkit/test/errors.test.ts +++ b/packages/agent-toolkit/test/errors.test.ts @@ -4,6 +4,8 @@ import { BusinessError, ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, ValidationError, } from '../src/errors'; @@ -67,6 +69,8 @@ describe('errors', () => { { ErrorClass: UnprocessableError, errorName: 'UnprocessableError' }, { ErrorClass: ForbiddenError, errorName: 'ForbiddenError' }, { ErrorClass: NotFoundError, errorName: 'NotFoundError' }, + { ErrorClass: UnauthorizedError, errorName: 'UnauthorizedError' }, + { ErrorClass: TooManyRequestsError, errorName: 'TooManyRequestsError' }, ])('$errorName should have the correct baseBusinessErrorName', ({ ErrorClass, errorName }) => { const error = new ErrorClass('test'); expect(error.baseBusinessErrorName).toEqual(errorName); diff --git a/packages/agent/src/routes/system/error-handling.ts b/packages/agent/src/routes/system/error-handling.ts index 1b717563ee..515de51377 100644 --- a/packages/agent/src/routes/system/error-handling.ts +++ b/packages/agent/src/routes/system/error-handling.ts @@ -6,6 +6,8 @@ import { BusinessError, ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, ValidationError, } from '@forestadmin/datasource-toolkit'; @@ -60,6 +62,10 @@ export default class ErrorHandling extends BaseRoute { case BusinessError.isOfType(error, BadRequestError): return HttpCode.BadRequest; + case error instanceof UnauthorizedError: + case BusinessError.isOfType(error, UnauthorizedError): + return HttpCode.Unauthorized; + case error instanceof ForbiddenError: case BusinessError.isOfType(error, ForbiddenError): return HttpCode.Forbidden; @@ -68,6 +74,10 @@ export default class ErrorHandling extends BaseRoute { case BusinessError.isOfType(error, NotFoundError): return HttpCode.NotFound; + case error instanceof TooManyRequestsError: + case BusinessError.isOfType(error, TooManyRequestsError): + return HttpCode.TooManyRequests; + case error instanceof UnprocessableError: case BusinessError.isOfType(error, UnprocessableError): case error instanceof BusinessError: diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index c003a86880..d90d83b084 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -52,12 +52,14 @@ export type HttpCallback = (req: IncomingMessage, res: ServerResponse, next?: () export enum HttpCode { BadRequest = 400, + Unauthorized = 401, Forbidden = 403, + NotFound = 404, + Unprocessable = 422, + TooManyRequests = 429, InternalServerError = 500, NoContent = 204, - NotFound = 404, Ok = 200, - Unprocessable = 422, } export enum RouteType { diff --git a/packages/agent/test/routes/system/error-handling.test.ts b/packages/agent/test/routes/system/error-handling.test.ts index 41f4f59423..935763671c 100644 --- a/packages/agent/test/routes/system/error-handling.test.ts +++ b/packages/agent/test/routes/system/error-handling.test.ts @@ -4,6 +4,8 @@ import { BadRequestError, ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, ValidationError, } from '@forestadmin/datasource-toolkit'; @@ -133,6 +135,32 @@ describe('ErrorHandling', () => { expect(console.error).not.toHaveBeenCalled(); }); + test('it should set the status and body for unauthorized errors', async () => { + const context = createMockContext(); + const next = jest.fn().mockRejectedValue(new UnauthorizedError('unauthorized')); + + await expect(handleError.call(route, context, next)).rejects.toThrow(); + + expect(context.response.status).toStrictEqual(HttpCode.Unauthorized); + expect(context.response.body).toStrictEqual({ + errors: [{ detail: 'unauthorized', name: 'UnauthorizedError', status: 401 }], + }); + expect(console.error).not.toHaveBeenCalled(); + }); + + test('it should set the status and body for too many requests errors', async () => { + const context = createMockContext(); + const next = jest.fn().mockRejectedValue(new TooManyRequestsError('rate limited')); + + await expect(handleError.call(route, context, next)).rejects.toThrow(); + + expect(context.response.status).toStrictEqual(HttpCode.TooManyRequests); + expect(context.response.body).toStrictEqual({ + errors: [{ detail: 'rate limited', name: 'TooManyRequestsError', status: 429 }], + }); + expect(console.error).not.toHaveBeenCalled(); + }); + test('it should set the status and body for other errors and prevent information leak', async () => { const context = createMockContext(); const next = jest @@ -258,6 +286,8 @@ describe('ErrorHandling', () => { { Error: UnprocessableError, errorName: 'UnprocessableError' }, { Error: ForbiddenError, errorName: 'ForbiddenError' }, { Error: NotFoundError, errorName: 'NotFoundError' }, + { Error: UnauthorizedError, errorName: 'UnauthorizedError' }, + { Error: TooManyRequestsError, errorName: 'TooManyRequestsError' }, ])('should have the right baseBusinessErrorName', ({ Error, errorName }) => { const extendedError = new Error('message'); diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index e47feadcd2..6ab2f0149f 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -1,23 +1,18 @@ /** - * All custom AI errors extend HTTP-status error classes (BadRequestError, NotFoundError, - * UnprocessableError) from datasource-toolkit. This allows the agent's error middleware - * to map them to their natural HTTP status codes automatically. + * All custom AI errors extend their matching HTTP-status error class from datasource-toolkit. + * This allows the agent's error middleware to map them to their natural HTTP status codes. */ // eslint-disable-next-line max-classes-per-file import { BadRequestError, + ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, } from '@forestadmin/datasource-toolkit'; -export class AIError extends UnprocessableError { - constructor(message: string) { - super(message); - this.name = 'AIError'; - } -} - export class AIBadRequestError extends BadRequestError { constructor(message: string) { super(message); @@ -41,24 +36,66 @@ export class AINotFoundError extends NotFoundError { } } -export class AIUnprocessableError extends UnprocessableError { +export class AIProviderError extends UnprocessableError { + readonly provider: string; readonly cause?: Error; - constructor(message: string, options?: { cause?: Error }) { - super(message); - this.name = 'AIUnprocessableError'; + constructor(provider: string, options?: { cause?: Error }) { + super(`Error while calling ${provider}: ${options?.cause?.message ?? 'unknown'}`); + this.name = 'AIProviderError'; + this.provider = provider; + if (options?.cause) this.cause = options.cause; + } +} + +export class AITooManyRequestsError extends TooManyRequestsError { + readonly provider: string; + readonly cause?: Error; + + constructor(provider: string, options?: { cause?: Error }) { + super(`${provider} rate limit exceeded: ${options?.cause?.message ?? 'unknown reason'}`); + this.name = 'AITooManyRequestsError'; + this.provider = provider; + if (options?.cause) this.cause = options.cause; + } +} + +export class AIUnauthorizedError extends UnauthorizedError { + readonly provider: string; + readonly cause?: Error; + + constructor(provider: string, options?: { cause?: Error }) { + super( + `${provider} authentication failed: ${ + options?.cause?.message ?? 'check your API key configuration' + }`, + ); + this.name = 'AIUnauthorizedError'; + this.provider = provider; + if (options?.cause) this.cause = options.cause; + } +} + +export class AIForbiddenError extends ForbiddenError { + readonly provider: string; + readonly cause?: Error; + + constructor(provider: string, options?: { cause?: Error }) { + super(`${provider} access denied: ${options?.cause?.message ?? 'permission denied'}`); + this.name = 'AIForbiddenError'; + this.provider = provider; if (options?.cause) this.cause = options.cause; } } -export class AINotConfiguredError extends AIError { +export class AINotConfiguredError extends UnprocessableError { constructor(message = 'AI is not configured') { super(message); this.name = 'AINotConfiguredError'; } } -export class AIToolUnprocessableError extends AIUnprocessableError { +export class AIToolUnprocessableError extends UnprocessableError { constructor(message: string) { super(message); this.name = 'AIToolUnprocessableError'; @@ -72,7 +109,7 @@ export class AIToolNotFoundError extends AINotFoundError { } } -export class McpError extends AIError { +export class McpError extends UnprocessableError { constructor(message: string) { super(message); this.name = 'McpError'; diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index de285bf60c..4d17d29e8e 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -4,12 +4,20 @@ import type { RemoteTools } from './remote-tools'; import type { DispatchBody } from './schemas/route'; import type { AIMessage, BaseMessageLike } from '@langchain/core/messages'; +import { BusinessError, UnprocessableError } from '@forestadmin/datasource-toolkit'; import { ChatAnthropic } from '@langchain/anthropic'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; import { ChatOpenAI } from '@langchain/openai'; import AnthropicAdapter from './anthropic-adapter'; -import { AIBadRequestError, AINotConfiguredError, AIUnprocessableError } from './errors'; +import { + AIBadRequestError, + AIForbiddenError, + AINotConfiguredError, + AIProviderError, + AITooManyRequestsError, + AIUnauthorizedError, +} from './errors'; import { LangChainAdapter } from './langchain-adapter'; // Re-export types for consumers @@ -100,7 +108,7 @@ export default class ProviderDispatcher { const rawResponse = response.additional_kwargs.__raw_response as ChatCompletionResponse; if (!rawResponse) { - throw new AIUnprocessableError( + throw new UnprocessableError( 'OpenAI response missing raw response data. This may indicate an API change.', ); } @@ -138,42 +146,20 @@ export default class ProviderDispatcher { return LangChainAdapter.convertResponse(response, this.modelName); } - /** - * Wraps provider errors into AI-specific error types. - * - * TODO: Currently all provider errors are wrapped as AIUnprocessableError, - * losing the original HTTP semantics (429 rate limit, 401 auth failure). - * To fix this properly we need to: - * 1. Add UnauthorizedError and TooManyRequestsError to datasource-toolkit - * 2. Add corresponding cases in the agent's error-handling middleware - * 3. Create AIProviderError, AIRateLimitError, AIAuthenticationError in ai-proxy - * with baseBusinessErrorName overrides for correct HTTP status mapping - */ private static wrapProviderError(error: unknown, providerName: string): Error { - if (error instanceof AIUnprocessableError) return error; - if (error instanceof AIBadRequestError) return error; + if (error instanceof BusinessError) return error; if (!(error instanceof Error)) { - return new AIUnprocessableError(`Error while calling ${providerName}: ${String(error)}`); + return new AIProviderError(providerName, { cause: new Error(JSON.stringify(error)) }); } const { status } = error as Error & { status?: number }; - if (status === 429) { - return new AIUnprocessableError(`${providerName} rate limit exceeded: ${error.message}`, { - cause: error, - }); - } - - if (status === 401) { - return new AIUnprocessableError(`${providerName} authentication failed: ${error.message}`, { - cause: error, - }); - } + if (status === 429) return new AITooManyRequestsError(providerName, { cause: error }); + if (status === 401) return new AIUnauthorizedError(providerName, { cause: error }); + if (status === 403) return new AIForbiddenError(providerName, { cause: error }); - return new AIUnprocessableError(`Error while calling ${providerName}: ${error.message}`, { - cause: error, - }); + return new AIProviderError(providerName, { cause: error }); } private enrichToolDefinitions(tools?: ChatCompletionTool[]): ChatCompletionTool[] | undefined { diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts index f3a1569ec9..441574f27c 100644 --- a/packages/ai-proxy/test/errors.test.ts +++ b/packages/ai-proxy/test/errors.test.ts @@ -1,18 +1,23 @@ import { BadRequestError, + ForbiddenError, NotFoundError, + TooManyRequestsError, + UnauthorizedError, UnprocessableError, } from '@forestadmin/datasource-toolkit'; import { AIBadRequestError, - AIError, + AIForbiddenError, AIModelNotSupportedError, AINotConfiguredError, AINotFoundError, + AIProviderError, + AITooManyRequestsError, + AIUnauthorizedError, AIToolNotFoundError, AIToolUnprocessableError, - AIUnprocessableError, McpConfigError, McpConflictError, McpConnectionError, @@ -21,20 +26,24 @@ import { describe('AI Error Hierarchy', () => { describe('UnprocessableError branch (422)', () => { - test('AIError extends UnprocessableError', () => { - const error = new AIError('test'); + test('AINotConfiguredError extends UnprocessableError', () => { + const error = new AINotConfiguredError(); expect(error).toBeInstanceOf(UnprocessableError); }); - test('AINotConfiguredError extends UnprocessableError via AIError', () => { - const error = new AINotConfiguredError(); - expect(error).toBeInstanceOf(AIError); + test('AIToolUnprocessableError extends UnprocessableError', () => { + const error = new AIToolUnprocessableError('test'); expect(error).toBeInstanceOf(UnprocessableError); }); - test('McpError extends UnprocessableError via AIError', () => { + test('AIProviderError extends UnprocessableError', () => { + const error = new AIProviderError('OpenAI', { cause: new Error('test') }); + expect(error).toBeInstanceOf(UnprocessableError); + expect(error.provider).toBe('OpenAI'); + }); + + test('McpError extends UnprocessableError', () => { const error = new McpError('test'); - expect(error).toBeInstanceOf(AIError); expect(error).toBeInstanceOf(UnprocessableError); }); @@ -55,17 +64,33 @@ describe('AI Error Hierarchy', () => { expect(error).toBeInstanceOf(McpError); expect(error).toBeInstanceOf(UnprocessableError); }); + }); + + describe('TooManyRequestsError branch (429)', () => { + test('AITooManyRequestsError extends TooManyRequestsError', () => { + const error = new AITooManyRequestsError('OpenAI'); + expect(error).toBeInstanceOf(TooManyRequestsError); + expect(error.provider).toBe('OpenAI'); + expect(error.httpCode).toBe(429); + }); + }); - test('AIUnprocessableError extends UnprocessableError', () => { - const error = new AIUnprocessableError('test'); - expect(error).toBeInstanceOf(UnprocessableError); + describe('UnauthorizedError branch (401)', () => { + test('AIUnauthorizedError extends UnauthorizedError', () => { + const error = new AIUnauthorizedError('OpenAI'); + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.provider).toBe('OpenAI'); + expect(error.httpCode).toBe(401); }); + }); - test('AIToolUnprocessableError extends UnprocessableError via AIUnprocessableError', () => { - const error = new AIToolUnprocessableError('test'); - expect(error).toBeInstanceOf(AIUnprocessableError); - expect(error).toBeInstanceOf(UnprocessableError); - expect(error.name).toBe('AIToolUnprocessableError'); + describe('ForbiddenError branch (403)', () => { + test('AIForbiddenError extends ForbiddenError', () => { + const error = new AIForbiddenError('OpenAI', { cause: new Error('model access denied') }); + expect(error).toBeInstanceOf(ForbiddenError); + expect(error.provider).toBe('OpenAI'); + expect(error.httpCode).toBe(403); + expect(error.message).toBe('OpenAI access denied: model access denied'); }); }); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index d32ac6d006..ce90a2fe7a 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -6,8 +6,11 @@ import { ChatOpenAI } from '@langchain/openai'; import { AIBadRequestError, + AIForbiddenError, AINotConfiguredError, - AIUnprocessableError, + AIProviderError, + AITooManyRequestsError, + AIUnauthorizedError, ProviderDispatcher, RemoteTools, } from '../src'; @@ -149,36 +152,63 @@ describe('ProviderDispatcher', () => { }); describe('error handling', () => { - it('should wrap generic errors as AIUnprocessableError with cause', async () => { + it('should not wrap BusinessError thrown during invocation', async () => { + const error = new AINotConfiguredError(); + invokeMock.mockRejectedValueOnce(error); + + const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); + + expect(thrown).toBe(error); + }); + + it('should wrap generic errors as AIProviderError with cause', async () => { const original = new Error('OpenAI error'); invokeMock.mockRejectedValueOnce(original); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AIProviderError); expect(thrown.message).toBe('Error while calling OpenAI: OpenAI error'); + expect(thrown.provider).toBe('OpenAI'); expect(thrown.cause).toBe(original); }); - it('should wrap 429 as AIUnprocessableError with rate limit message', async () => { + it('should wrap 429 as AITooManyRequestsError', async () => { const error = Object.assign(new Error('Too many requests'), { status: 429 }); invokeMock.mockRejectedValueOnce(error); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AITooManyRequestsError); expect(thrown.message).toBe('OpenAI rate limit exceeded: Too many requests'); + expect(thrown.provider).toBe('OpenAI'); + expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); }); - it('should wrap 401 as AIUnprocessableError with auth message', async () => { + it('should wrap 401 as AIUnauthorizedError', async () => { const error = Object.assign(new Error('Invalid API key'), { status: 401 }); invokeMock.mockRejectedValueOnce(error); const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AIUnauthorizedError); expect(thrown.message).toBe('OpenAI authentication failed: Invalid API key'); + expect(thrown.provider).toBe('OpenAI'); + expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); + expect(thrown.cause).toBe(error); + }); + + it('should wrap 403 as AIForbiddenError', async () => { + const error = Object.assign(new Error('Access denied'), { status: 403 }); + invokeMock.mockRejectedValueOnce(error); + + const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); + + expect(thrown).toBeInstanceOf(AIForbiddenError); + expect(thrown.message).toBe('OpenAI access denied: Access denied'); + expect(thrown.provider).toBe('OpenAI'); + expect(thrown.baseBusinessErrorName).toBe('ForbiddenError'); expect(thrown.cause).toBe(error); }); @@ -388,7 +418,7 @@ describe('ProviderDispatcher', () => { }); describe('error handling', () => { - it('should wrap generic errors as AIUnprocessableError with cause', async () => { + it('should wrap generic errors as AIProviderError with cause', async () => { const original = new Error('Anthropic API error'); anthropicInvokeMock.mockRejectedValueOnce(original); @@ -396,12 +426,13 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AIProviderError); expect(thrown.message).toBe('Error while calling Anthropic: Anthropic API error'); + expect(thrown.provider).toBe('Anthropic'); expect(thrown.cause).toBe(original); }); - it('should wrap 429 as AIUnprocessableError with rate limit message', async () => { + it('should wrap 429 as AITooManyRequestsError', async () => { const error = Object.assign(new Error('Too many requests'), { status: 429 }); anthropicInvokeMock.mockRejectedValueOnce(error); @@ -409,12 +440,14 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AITooManyRequestsError); expect(thrown.message).toBe('Anthropic rate limit exceeded: Too many requests'); + expect(thrown.provider).toBe('Anthropic'); + expect(thrown.baseBusinessErrorName).toBe('TooManyRequestsError'); expect(thrown.cause).toBe(error); }); - it('should wrap 401 as AIUnprocessableError with auth message', async () => { + it('should wrap 401 as AIUnauthorizedError', async () => { const error = Object.assign(new Error('Invalid API key'), { status: 401 }); anthropicInvokeMock.mockRejectedValueOnce(error); @@ -422,8 +455,25 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); + expect(thrown).toBeInstanceOf(AIUnauthorizedError); expect(thrown.message).toBe('Anthropic authentication failed: Invalid API key'); + expect(thrown.provider).toBe('Anthropic'); + expect(thrown.baseBusinessErrorName).toBe('UnauthorizedError'); + expect(thrown.cause).toBe(error); + }); + + it('should wrap 403 as AIForbiddenError', async () => { + const error = Object.assign(new Error('Access denied'), { status: 403 }); + anthropicInvokeMock.mockRejectedValueOnce(error); + + const thrown = await dispatcher + .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) + .catch(e => e); + + expect(thrown).toBeInstanceOf(AIForbiddenError); + expect(thrown.message).toBe('Anthropic access denied: Access denied'); + expect(thrown.provider).toBe('Anthropic'); + expect(thrown.baseBusinessErrorName).toBe('ForbiddenError'); expect(thrown.cause).toBe(error); }); @@ -434,8 +484,8 @@ describe('ProviderDispatcher', () => { .dispatch(buildBody({ messages: [{ role: 'user', content: 'Hello' }] })) .catch(e => e); - expect(thrown).toBeInstanceOf(AIUnprocessableError); - expect(thrown.message).toBe('Error while calling Anthropic: string error'); + expect(thrown).toBeInstanceOf(AIProviderError); + expect(thrown.message).toBe('Error while calling Anthropic: "string error"'); }); it('should not wrap conversion errors as provider errors', async () => { diff --git a/packages/datasource-toolkit/src/errors.ts b/packages/datasource-toolkit/src/errors.ts index 304a84a056..90ac0709d5 100644 --- a/packages/datasource-toolkit/src/errors.ts +++ b/packages/datasource-toolkit/src/errors.ts @@ -9,6 +9,8 @@ export { UnprocessableError, ForbiddenError, NotFoundError, + UnauthorizedError, + TooManyRequestsError, } from '@forestadmin/agent-toolkit'; export class IntrospectionFormatError extends BusinessError { @@ -16,8 +18,7 @@ export class IntrospectionFormatError extends BusinessError { const message = `This version of introspection is newer than this package version. ` + `Please update ${sourcePackageName}`; - super(message); - this.baseBusinessErrorName = 'IntrospectionFormatError'; + super(message, undefined, undefined, 422, 'IntrospectionFormatError'); } /** @deprecated use name instead */