diff --git a/.changeset/thirty-poems-shop.md b/.changeset/thirty-poems-shop.md new file mode 100644 index 0000000..9380199 --- /dev/null +++ b/.changeset/thirty-poems-shop.md @@ -0,0 +1,5 @@ +--- +'thatopen-services': minor +--- + +Surface structured API errors via a new RequestError class diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index d31130f..3408561 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -10,6 +10,7 @@ import { import { createBundleZip } from '../lib/zip'; import { declarationsPath, readDeclarations } from '../lib/declarations'; import { EngineServicesClient } from '../../core/client'; +import { RequestError } from '../../core/request-error'; export const publishCommand = new Command('publish') .description('Build and publish the project to the ThatOpen platform') @@ -148,21 +149,31 @@ export const publishCommand = new Command('publish') console.log('Published successfully!'); } catch (err) { - const message = (err as Error).message || String(err); - if (message.includes('401') || message.includes('403')) { - console.error( - 'Authentication failed. Check your token with `thatopen login`.', - ); - } else if ( - message.includes('fetch') || - message.includes('ECONNREFUSED') - ) { - console.error( - 'Could not connect to the platform. Is the API URL correct?', - ); - console.error(` API URL: ${config.apiUrl}`); + if (err instanceof RequestError) { + if (err.code === 'LIMIT_EXCEEDED') { + console.error(err.message); + } else if (err.status === 401) { + console.error( + 'Authentication failed. Check your token with `thatopen login`.', + ); + } else if (err.status === 403) { + console.error(`Permission denied: ${err.message}`); + } else { + console.error('Upload failed:', err.message); + } } else { - console.error('Upload failed:', message); + const message = (err as Error).message || String(err); + if ( + message.includes('fetch') || + message.includes('ECONNREFUSED') + ) { + console.error( + 'Could not connect to the platform. Is the API URL correct?', + ); + console.error(` API URL: ${config.apiUrl}`); + } else { + console.error('Upload failed:', message); + } } process.exit(1); } diff --git a/src/cli/templates/test/src/main.ts b/src/cli/templates/test/src/main.ts index 3fd3e32..91182a4 100644 --- a/src/cli/templates/test/src/main.ts +++ b/src/cli/templates/test/src/main.ts @@ -797,7 +797,12 @@ async function runAllTests(resultsEl: HTMLElement, client: PlatformClient, compo try { await client.abortExecution(result.executionId); } catch (err) { - if (!(err instanceof Error && err.message.includes("4"))) throw err; + const status = + err && typeof err === "object" && "status" in err + ? Number((err as { status?: number }).status) + : 0; + const is4xx = status >= 400 && status < 500; + if (!is4xx) throw err; } }), ); @@ -886,7 +891,12 @@ async function runAllTests(resultsEl: HTMLElement, client: PlatformClient, compo try { await client.abortExecution(result.executionId); } catch (err) { - if (!(err instanceof Error && err.message.includes("4"))) throw err; + const status = + err && typeof err === "object" && "status" in err + ? Number((err as { status?: number }).status) + : 0; + const is4xx = status >= 400 && status < 500; + if (!is4xx) throw err; } }), ); diff --git a/src/core/client.test.ts b/src/core/client.test.ts index 75b8665..d228e1e 100644 --- a/src/core/client.test.ts +++ b/src/core/client.test.ts @@ -224,6 +224,30 @@ describe('EngineServicesClient — HTTP contract', () => { client.executeComponent('comp-1', { projectId: 'foreign' }), ).rejects.toThrow(/403/); }); + + it('throws a RequestError exposing status, code and details from the body', async () => { + const body = JSON.stringify({ + message: 'Components limit reached (10/10).', + code: 'LIMIT_EXCEEDED', + details: { limitType: 'componentsPerAccount', current: 10, max: 10 }, + }); + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + text: async () => body, + json: async () => JSON.parse(body), + } as unknown as Response); + const client = new EngineServicesClient(TOKEN, API); + await expect(client.executeComponent('comp-1', {})).rejects.toMatchObject( + { + name: 'RequestError', + status: 403, + code: 'LIMIT_EXCEEDED', + message: 'Components limit reached (10/10).', + }, + ); + }); }); describe('file version metadata', () => { diff --git a/src/core/client.ts b/src/core/client.ts index 7f3e332..d0e0cc5 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -22,6 +22,7 @@ import { Metadata, } from '../types/files'; import { ThatOpenContext } from '../types/context'; +import { RequestError } from './request-error'; declare global { interface Window { @@ -340,9 +341,11 @@ export class EngineServicesClient { const textResponse = await response .text() .then((text) => text) - .catch(() => undefined); - throw new Error( - `Request failed with status ${response.status}: ${response.statusText} - ${textResponse}`, + .catch(() => ''); + throw new RequestError( + response.status, + response.statusText, + textResponse, ); } diff --git a/src/core/request-error.test.ts b/src/core/request-error.test.ts new file mode 100644 index 0000000..30e4f0d --- /dev/null +++ b/src/core/request-error.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { RequestError } from './request-error'; + +describe('RequestError', () => { + it('extracts message, code and details from a structured JSON body', () => { + const body = JSON.stringify({ + message: 'Components limit reached (10/10).', + code: 'LIMIT_EXCEEDED', + details: { limitType: 'componentsPerAccount', current: 10, max: 10 }, + }); + const err = new RequestError(403, 'Forbidden', body); + expect(err.status).toBe(403); + expect(err.code).toBe('LIMIT_EXCEEDED'); + expect(err.details).toEqual({ + limitType: 'componentsPerAccount', + current: 10, + max: 10, + }); + expect(err.message).toBe('Components limit reached (10/10).'); + expect(err.body).toBe(body); + }); + + it('leaves code and details undefined when the body has only a message', () => { + const err = new RequestError(404, 'Not Found', JSON.stringify({ + message: 'Item not found', + })); + expect(err.message).toBe('Item not found'); + expect(err.code).toBeUndefined(); + expect(err.details).toBeUndefined(); + }); + + it('falls back to a status line when the body is not JSON', () => { + const err = new RequestError(502, 'Bad Gateway', 'error'); + expect(err.message).toBe('Bad Gateway (502)'); + expect(err.code).toBeUndefined(); + expect(err.details).toBeUndefined(); + expect(err.body).toBe('error'); + }); + + it('falls back to a status line for an empty body', () => { + const err = new RequestError(500, 'Internal Server Error', ''); + expect(err.message).toBe('Internal Server Error (500)'); + }); + + it('falls back when the JSON body is not an object', () => { + const err = new RequestError(400, 'Bad Request', '"just a string"'); + expect(err.message).toBe('Bad Request (400)'); + expect(err.code).toBeUndefined(); + }); + + it('ignores non-string message and code fields', () => { + const err = new RequestError(400, 'Bad Request', JSON.stringify({ + message: 123, + code: { nested: true }, + })); + expect(err.message).toBe('Bad Request (400)'); + expect(err.code).toBeUndefined(); + }); + + it('is an instance of Error and RequestError with the right name', () => { + const err = new RequestError(403, 'Forbidden', ''); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(RequestError); + expect(err.name).toBe('RequestError'); + }); +}); diff --git a/src/core/request-error.ts b/src/core/request-error.ts new file mode 100644 index 0000000..fd48aff --- /dev/null +++ b/src/core/request-error.ts @@ -0,0 +1,58 @@ +/** + * Parses an API error body. The platform returns `{ message, code?, details? }` + * as JSON on failures; non-JSON bodies (proxies, gateways, plain text) yield an + * empty result so the caller falls back to the status line. + */ +function parseErrorBody(body: string): { + message?: string; + code?: string; + details?: unknown; +} { + try { + const json: unknown = JSON.parse(body); + if (json && typeof json === 'object') { + const obj = json as Record; + return { + message: typeof obj.message === 'string' ? obj.message : undefined, + code: typeof obj.code === 'string' ? obj.code : undefined, + details: obj.details, + }; + } + } catch {} + return {}; +} + +/** + * Error thrown by {@link EngineServicesClient} when the platform API responds + * with a non-2xx status. Exposes the HTTP `status` and — when the API returns a + * structured JSON body — its `code` and `details`, so callers can react to + * specific failures (e.g. `code === 'LIMIT_EXCEEDED'`) instead of string- + * matching the message. + * + * @example + * ```ts + * try { + * await client.createComponent(props); + * } catch (err) { + * if (err instanceof RequestError && err.code === 'LIMIT_EXCEEDED') { + * console.error(err.message); // "Components limit reached (10/10)..." + * } + * } + * ``` + */ +export class RequestError extends Error { + readonly status: number; + readonly code?: string; + readonly details?: unknown; + readonly body: string; + + constructor(status: number, statusText: string, body: string) { + const parsed = parseErrorBody(body); + super(parsed.message ?? `${statusText || 'Request failed'} (${status})`); + this.name = 'RequestError'; + this.status = status; + this.code = parsed.code; + this.details = parsed.details; + this.body = body; + } +} diff --git a/src/index.ts b/src/index.ts index 8cf8eac..3e76f8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './core/client'; export * from './core/platform-client'; +export * from './core/request-error'; export * from './types/items'; export * from './types/base'; export * from './types/execution';