Skip to content

Commit b598e3c

Browse files
feat: expose structured API errors via RequestError (#15)
Co-authored-by: smoratino-apogea <>
1 parent 4caeac5 commit b598e3c

8 files changed

Lines changed: 197 additions & 19 deletions

File tree

.changeset/thirty-poems-shop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'thatopen-services': minor
3+
---
4+
5+
Surface structured API errors via a new RequestError class

src/cli/commands/publish.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { createBundleZip } from '../lib/zip';
1111
import { declarationsPath, readDeclarations } from '../lib/declarations';
1212
import { EngineServicesClient } from '../../core/client';
13+
import { RequestError } from '../../core/request-error';
1314

1415
export const publishCommand = new Command('publish')
1516
.description('Build and publish the project to the ThatOpen platform')
@@ -148,21 +149,31 @@ export const publishCommand = new Command('publish')
148149

149150
console.log('Published successfully!');
150151
} catch (err) {
151-
const message = (err as Error).message || String(err);
152-
if (message.includes('401') || message.includes('403')) {
153-
console.error(
154-
'Authentication failed. Check your token with `thatopen login`.',
155-
);
156-
} else if (
157-
message.includes('fetch') ||
158-
message.includes('ECONNREFUSED')
159-
) {
160-
console.error(
161-
'Could not connect to the platform. Is the API URL correct?',
162-
);
163-
console.error(` API URL: ${config.apiUrl}`);
152+
if (err instanceof RequestError) {
153+
if (err.code === 'LIMIT_EXCEEDED') {
154+
console.error(err.message);
155+
} else if (err.status === 401) {
156+
console.error(
157+
'Authentication failed. Check your token with `thatopen login`.',
158+
);
159+
} else if (err.status === 403) {
160+
console.error(`Permission denied: ${err.message}`);
161+
} else {
162+
console.error('Upload failed:', err.message);
163+
}
164164
} else {
165-
console.error('Upload failed:', message);
165+
const message = (err as Error).message || String(err);
166+
if (
167+
message.includes('fetch') ||
168+
message.includes('ECONNREFUSED')
169+
) {
170+
console.error(
171+
'Could not connect to the platform. Is the API URL correct?',
172+
);
173+
console.error(` API URL: ${config.apiUrl}`);
174+
} else {
175+
console.error('Upload failed:', message);
176+
}
166177
}
167178
process.exit(1);
168179
}

src/cli/templates/test/src/main.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,12 @@ async function runAllTests(resultsEl: HTMLElement, client: PlatformClient, compo
797797
try {
798798
await client.abortExecution(result.executionId);
799799
} catch (err) {
800-
if (!(err instanceof Error && err.message.includes("4"))) throw err;
800+
const status =
801+
err && typeof err === "object" && "status" in err
802+
? Number((err as { status?: number }).status)
803+
: 0;
804+
const is4xx = status >= 400 && status < 500;
805+
if (!is4xx) throw err;
801806
}
802807
}),
803808
);
@@ -886,7 +891,12 @@ async function runAllTests(resultsEl: HTMLElement, client: PlatformClient, compo
886891
try {
887892
await client.abortExecution(result.executionId);
888893
} catch (err) {
889-
if (!(err instanceof Error && err.message.includes("4"))) throw err;
894+
const status =
895+
err && typeof err === "object" && "status" in err
896+
? Number((err as { status?: number }).status)
897+
: 0;
898+
const is4xx = status >= 400 && status < 500;
899+
if (!is4xx) throw err;
890900
}
891901
}),
892902
);

src/core/client.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,30 @@ describe('EngineServicesClient — HTTP contract', () => {
224224
client.executeComponent('comp-1', { projectId: 'foreign' }),
225225
).rejects.toThrow(/403/);
226226
});
227+
228+
it('throws a RequestError exposing status, code and details from the body', async () => {
229+
const body = JSON.stringify({
230+
message: 'Components limit reached (10/10).',
231+
code: 'LIMIT_EXCEEDED',
232+
details: { limitType: 'componentsPerAccount', current: 10, max: 10 },
233+
});
234+
fetchMock.mockResolvedValue({
235+
ok: false,
236+
status: 403,
237+
statusText: 'Forbidden',
238+
text: async () => body,
239+
json: async () => JSON.parse(body),
240+
} as unknown as Response);
241+
const client = new EngineServicesClient(TOKEN, API);
242+
await expect(client.executeComponent('comp-1', {})).rejects.toMatchObject(
243+
{
244+
name: 'RequestError',
245+
status: 403,
246+
code: 'LIMIT_EXCEEDED',
247+
message: 'Components limit reached (10/10).',
248+
},
249+
);
250+
});
227251
});
228252

229253
describe('file version metadata', () => {

src/core/client.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
Metadata,
2323
} from '../types/files';
2424
import { ThatOpenContext } from '../types/context';
25+
import { RequestError } from './request-error';
2526

2627
declare global {
2728
interface Window {
@@ -340,9 +341,11 @@ export class EngineServicesClient {
340341
const textResponse = await response
341342
.text()
342343
.then((text) => text)
343-
.catch(() => undefined);
344-
throw new Error(
345-
`Request failed with status ${response.status}: ${response.statusText} - ${textResponse}`,
344+
.catch(() => '');
345+
throw new RequestError(
346+
response.status,
347+
response.statusText,
348+
textResponse,
346349
);
347350
}
348351

src/core/request-error.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { RequestError } from './request-error';
3+
4+
describe('RequestError', () => {
5+
it('extracts message, code and details from a structured JSON body', () => {
6+
const body = JSON.stringify({
7+
message: 'Components limit reached (10/10).',
8+
code: 'LIMIT_EXCEEDED',
9+
details: { limitType: 'componentsPerAccount', current: 10, max: 10 },
10+
});
11+
const err = new RequestError(403, 'Forbidden', body);
12+
expect(err.status).toBe(403);
13+
expect(err.code).toBe('LIMIT_EXCEEDED');
14+
expect(err.details).toEqual({
15+
limitType: 'componentsPerAccount',
16+
current: 10,
17+
max: 10,
18+
});
19+
expect(err.message).toBe('Components limit reached (10/10).');
20+
expect(err.body).toBe(body);
21+
});
22+
23+
it('leaves code and details undefined when the body has only a message', () => {
24+
const err = new RequestError(404, 'Not Found', JSON.stringify({
25+
message: 'Item not found',
26+
}));
27+
expect(err.message).toBe('Item not found');
28+
expect(err.code).toBeUndefined();
29+
expect(err.details).toBeUndefined();
30+
});
31+
32+
it('falls back to a status line when the body is not JSON', () => {
33+
const err = new RequestError(502, 'Bad Gateway', '<html>error</html>');
34+
expect(err.message).toBe('Bad Gateway (502)');
35+
expect(err.code).toBeUndefined();
36+
expect(err.details).toBeUndefined();
37+
expect(err.body).toBe('<html>error</html>');
38+
});
39+
40+
it('falls back to a status line for an empty body', () => {
41+
const err = new RequestError(500, 'Internal Server Error', '');
42+
expect(err.message).toBe('Internal Server Error (500)');
43+
});
44+
45+
it('falls back when the JSON body is not an object', () => {
46+
const err = new RequestError(400, 'Bad Request', '"just a string"');
47+
expect(err.message).toBe('Bad Request (400)');
48+
expect(err.code).toBeUndefined();
49+
});
50+
51+
it('ignores non-string message and code fields', () => {
52+
const err = new RequestError(400, 'Bad Request', JSON.stringify({
53+
message: 123,
54+
code: { nested: true },
55+
}));
56+
expect(err.message).toBe('Bad Request (400)');
57+
expect(err.code).toBeUndefined();
58+
});
59+
60+
it('is an instance of Error and RequestError with the right name', () => {
61+
const err = new RequestError(403, 'Forbidden', '');
62+
expect(err).toBeInstanceOf(Error);
63+
expect(err).toBeInstanceOf(RequestError);
64+
expect(err.name).toBe('RequestError');
65+
});
66+
});

src/core/request-error.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Parses an API error body. The platform returns `{ message, code?, details? }`
3+
* as JSON on failures; non-JSON bodies (proxies, gateways, plain text) yield an
4+
* empty result so the caller falls back to the status line.
5+
*/
6+
function parseErrorBody(body: string): {
7+
message?: string;
8+
code?: string;
9+
details?: unknown;
10+
} {
11+
try {
12+
const json: unknown = JSON.parse(body);
13+
if (json && typeof json === 'object') {
14+
const obj = json as Record<string, unknown>;
15+
return {
16+
message: typeof obj.message === 'string' ? obj.message : undefined,
17+
code: typeof obj.code === 'string' ? obj.code : undefined,
18+
details: obj.details,
19+
};
20+
}
21+
} catch {}
22+
return {};
23+
}
24+
25+
/**
26+
* Error thrown by {@link EngineServicesClient} when the platform API responds
27+
* with a non-2xx status. Exposes the HTTP `status` and — when the API returns a
28+
* structured JSON body — its `code` and `details`, so callers can react to
29+
* specific failures (e.g. `code === 'LIMIT_EXCEEDED'`) instead of string-
30+
* matching the message.
31+
*
32+
* @example
33+
* ```ts
34+
* try {
35+
* await client.createComponent(props);
36+
* } catch (err) {
37+
* if (err instanceof RequestError && err.code === 'LIMIT_EXCEEDED') {
38+
* console.error(err.message); // "Components limit reached (10/10)..."
39+
* }
40+
* }
41+
* ```
42+
*/
43+
export class RequestError extends Error {
44+
readonly status: number;
45+
readonly code?: string;
46+
readonly details?: unknown;
47+
readonly body: string;
48+
49+
constructor(status: number, statusText: string, body: string) {
50+
const parsed = parseErrorBody(body);
51+
super(parsed.message ?? `${statusText || 'Request failed'} (${status})`);
52+
this.name = 'RequestError';
53+
this.status = status;
54+
this.code = parsed.code;
55+
this.details = parsed.details;
56+
this.body = body;
57+
}
58+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './core/client';
22
export * from './core/platform-client';
3+
export * from './core/request-error';
34
export * from './types/items';
45
export * from './types/base';
56
export * from './types/execution';

0 commit comments

Comments
 (0)