Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-poems-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'thatopen-services': minor
---

Surface structured API errors via a new RequestError class
39 changes: 25 additions & 14 deletions src/cli/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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);
}
Expand Down
14 changes: 12 additions & 2 deletions src/cli/templates/test/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}),
);
Expand Down Expand Up @@ -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;
}
}),
);
Expand Down
24 changes: 24 additions & 0 deletions src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 6 additions & 3 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Metadata,
} from '../types/files';
import { ThatOpenContext } from '../types/context';
import { RequestError } from './request-error';

declare global {
interface Window {
Expand Down Expand Up @@ -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,
);
}

Expand Down
66 changes: 66 additions & 0 deletions src/core/request-error.test.ts
Original file line number Diff line number Diff line change
@@ -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', '<html>error</html>');
expect(err.message).toBe('Bad Gateway (502)');
expect(err.code).toBeUndefined();
expect(err.details).toBeUndefined();
expect(err.body).toBe('<html>error</html>');
});

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');
});
});
58 changes: 58 additions & 0 deletions src/core/request-error.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading