Skip to content

Commit adb2381

Browse files
alban bertoliniclaude
andcommitted
refactor(ai-proxy): make AIError extend BusinessError for native error handling
AIError and subclasses now extend BusinessError from datasource-toolkit (BadRequestError, NotFoundError, UnprocessableError). The agent's error middleware handles HTTP status mapping natively, removing the need for duck-typed status checks and error re-wrapping in the route handler. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b2e9e76 commit adb2381

4 files changed

Lines changed: 42 additions & 174 deletions

File tree

packages/agent/src/routes/ai/ai-proxy.ts

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,6 @@ import type { AiRouter } from '@forestadmin/datasource-toolkit';
44
import type KoaRouter from '@koa/router';
55
import type { Context } from 'koa';
66

7-
import {
8-
BadRequestError,
9-
NotFoundError,
10-
UnprocessableError,
11-
} from '@forestadmin/datasource-toolkit';
12-
137
import { HttpCode, RouteType } from '../../types';
148
import BaseRoute from '../base-route';
159

@@ -31,30 +25,16 @@ export default class AiProxyRoute extends BaseRoute {
3125
}
3226

3327
private async handleAiProxy(context: Context): Promise<void> {
34-
try {
35-
const mcpServerConfigs =
36-
await this.options.forestAdminClient.mcpServerConfigService.getConfiguration();
37-
38-
context.response.body = await this.aiRouter.route({
39-
route: context.params.route,
40-
body: context.request.body,
41-
query: context.query,
42-
mcpServerConfigs,
43-
requestHeaders: context.request.headers,
44-
});
45-
context.response.status = HttpCode.Ok;
46-
} catch (error) {
47-
const err = error as Error & { status?: number };
48-
49-
if (typeof err.status === 'number' && err.status >= 400 && err.status < 600) {
50-
this.options.logger('Error', `AI proxy error: ${err.message}`, err);
51-
52-
if (err.status === 400) throw new BadRequestError(err.message);
53-
if (err.status === 404) throw new NotFoundError(err.message);
54-
throw new UnprocessableError(err.message);
55-
}
56-
57-
throw error;
58-
}
28+
const mcpServerConfigs =
29+
await this.options.forestAdminClient.mcpServerConfigService.getConfiguration();
30+
31+
context.response.body = await this.aiRouter.route({
32+
route: context.params.route,
33+
body: context.request.body,
34+
query: context.query,
35+
mcpServerConfigs,
36+
requestHeaders: context.request.headers,
37+
});
38+
context.response.status = HttpCode.Ok;
5939
}
6040
}
Lines changed: 11 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import type { AiRouter } from '@forestadmin/datasource-toolkit';
22

3-
import {
4-
BadRequestError,
5-
NotFoundError,
6-
UnprocessableError,
7-
} from '@forestadmin/datasource-toolkit';
83
import { createMockContext } from '@shopify/jest-koa-mocks';
94

105
import AiProxyRoute from '../../../src/routes/ai/ai-proxy';
@@ -116,124 +111,20 @@ describe('AiProxyRoute', () => {
116111
);
117112
});
118113

119-
describe('error handling', () => {
120-
test('should convert error with status 422 to UnprocessableError with original message', async () => {
121-
const route = new AiProxyRoute(services, options, aiRouter);
122-
const error = new Error('AI is not configured') as Error & { status: number };
123-
error.status = 422;
124-
mockRoute.mockRejectedValueOnce(error);
125-
126-
const context = createMockContext({
127-
customProperties: {
128-
params: { route: 'ai-query' },
129-
query: {},
130-
},
131-
requestBody: {},
132-
});
133-
134-
await expect((route as any).handleAiProxy(context)).rejects.toMatchObject({
135-
name: 'UnprocessableError',
136-
message: 'AI is not configured',
137-
});
138-
});
139-
140-
test('should convert error with status 404 to NotFoundError', async () => {
141-
const route = new AiProxyRoute(services, options, aiRouter);
142-
const error = new Error('Resource not found') as Error & { status: number };
143-
error.status = 404;
144-
mockRoute.mockRejectedValueOnce(error);
145-
146-
const context = createMockContext({
147-
customProperties: {
148-
params: { route: 'ai-query' },
149-
query: {},
150-
},
151-
requestBody: {},
152-
});
153-
154-
await expect((route as any).handleAiProxy(context)).rejects.toThrow(NotFoundError);
155-
});
156-
157-
test('should convert error with status 400 to BadRequestError', async () => {
158-
const route = new AiProxyRoute(services, options, aiRouter);
159-
const error = new Error('Invalid input') as Error & { status: number };
160-
error.status = 400;
161-
mockRoute.mockRejectedValueOnce(error);
162-
163-
const context = createMockContext({
164-
customProperties: {
165-
params: { route: 'ai-query' },
166-
query: {},
167-
},
168-
requestBody: {},
169-
});
170-
171-
await expect((route as any).handleAiProxy(context)).rejects.toThrow(BadRequestError);
172-
});
173-
174-
test('should convert error with other 4xx/5xx status to UnprocessableError', async () => {
175-
const route = new AiProxyRoute(services, options, aiRouter);
176-
const error = new Error('Server error') as Error & { status: number };
177-
error.status = 500;
178-
mockRoute.mockRejectedValueOnce(error);
179-
180-
const context = createMockContext({
181-
customProperties: {
182-
params: { route: 'ai-query' },
183-
query: {},
184-
},
185-
requestBody: {},
186-
});
187-
188-
await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError);
189-
});
190-
191-
test('should re-throw unknown errors unchanged', async () => {
192-
const route = new AiProxyRoute(services, options, aiRouter);
193-
const unknownError = new Error('Unknown error');
194-
mockRoute.mockRejectedValueOnce(unknownError);
195-
196-
const context = createMockContext({
197-
customProperties: {
198-
params: { route: 'ai-query' },
199-
},
200-
requestBody: {},
201-
});
202-
context.query = {};
203-
204-
const promise = (route as any).handleAiProxy(context);
114+
test('should let errors from aiRouter propagate unchanged', async () => {
115+
const route = new AiProxyRoute(services, options, aiRouter);
116+
const error = new Error('AI error');
117+
mockRoute.mockRejectedValueOnce(error);
205118

206-
await expect(promise).rejects.toBe(unknownError);
207-
expect(unknownError).not.toBeInstanceOf(UnprocessableError);
119+
const context = createMockContext({
120+
customProperties: {
121+
params: { route: 'ai-query' },
122+
query: {},
123+
},
124+
requestBody: {},
208125
});
209126

210-
test('should log AI proxy errors before converting them', async () => {
211-
const mockLogger = jest.fn();
212-
const optionsWithLogger = factories.forestAdminHttpDriverOptions.build({
213-
logger: mockLogger,
214-
});
215-
const route = new AiProxyRoute(services, optionsWithLogger, aiRouter);
216-
217-
const error = new Error('Some AI error') as Error & { status: number };
218-
error.status = 422;
219-
mockRoute.mockRejectedValueOnce(error);
220-
221-
const context = createMockContext({
222-
customProperties: {
223-
params: { route: 'ai-query' },
224-
query: {},
225-
},
226-
requestBody: {},
227-
});
228-
229-
await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError);
230-
231-
expect(mockLogger).toHaveBeenCalledWith(
232-
'Error',
233-
'AI proxy error: Some AI error',
234-
expect.any(Error),
235-
);
236-
});
127+
await expect((route as any).handleAiProxy(context)).rejects.toBe(error);
237128
});
238129
});
239130
});

packages/ai-proxy/src/errors.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,31 @@
11
/**
2-
* -------------------------------------
3-
* -------------------------------------
4-
* -------------------------------------
52
* All custom errors must extend the AIError class.
6-
* This inheritance is crucial for proper error translation
7-
* and consistent handling throughout the system.
8-
* -------------------------------------
9-
* -------------------------------------
10-
* -------------------------------------
3+
* AIError extends BusinessError from datasource-toolkit, which means
4+
* the agent's error middleware handles HTTP status mapping natively
5+
* via baseBusinessErrorName.
116
*/
127

138
// eslint-disable-next-line max-classes-per-file
14-
export class AIError extends Error {
15-
readonly status: number;
16-
17-
constructor(message: string, status = 422) {
18-
if (status < 100 || status > 599) {
19-
throw new RangeError(`Invalid HTTP status code: ${status}`);
20-
}
9+
import {
10+
BadRequestError,
11+
BusinessError,
12+
NotFoundError,
13+
UnprocessableError,
14+
} from '@forestadmin/datasource-toolkit';
2115

16+
export class AIError extends BusinessError {
17+
constructor(message: string) {
2218
super(message);
2319
this.name = 'AIError';
24-
this.status = status;
20+
this.baseBusinessErrorName = UnprocessableError.name;
2521
}
2622
}
2723

2824
export class AIBadRequestError extends AIError {
2925
constructor(message: string) {
30-
super(message, 400);
26+
super(message);
3127
this.name = 'AIBadRequestError';
28+
this.baseBusinessErrorName = BadRequestError.name;
3229
}
3330
}
3431

@@ -43,21 +40,22 @@ export class AIModelNotSupportedError extends AIBadRequestError {
4340

4441
export class AINotFoundError extends AIError {
4542
constructor(message: string) {
46-
super(message, 404);
43+
super(message);
4744
this.name = 'AINotFoundError';
45+
this.baseBusinessErrorName = NotFoundError.name;
4846
}
4947
}
5048

5149
export class AIUnprocessableError extends AIError {
5250
constructor(message: string) {
53-
super(message, 422);
51+
super(message);
5452
this.name = 'AIUnprocessableError';
5553
}
5654
}
5755

5856
export class AINotConfiguredError extends AIError {
5957
constructor(message = 'AI is not configured') {
60-
super(message, 422);
58+
super(message);
6159
this.name = 'AINotConfiguredError';
6260
}
6361
}

packages/datasource-toolkit/src/interfaces/ai.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ export interface AiRouter {
1010
/**
1111
* Route a request to the AI proxy.
1212
*
13-
* Implementations should throw errors with a numeric `status` property (e.g. 400, 404, 422)
14-
* for HTTP-status-based error translation. Errors without a `status` property
15-
* are treated as unexpected internal errors and re-thrown as-is.
13+
* Implementations should throw BusinessError subclasses (BadRequestError, NotFoundError,
14+
* UnprocessableError) for proper HTTP status mapping by the agent's error middleware.
1615
*/
1716
route(args: {
1817
route: string;

0 commit comments

Comments
 (0)