Skip to content

Commit 3b6cebb

Browse files
committed
fix(console): marketplace read cloud errors seven different ways — two of them break on the conversion, two are broken today (cloud#944)
`service-cloud` answers failures in two shapes and is mid-conversion between them (cloud#944): { success: false, error: '<message>' } today, via `fail()` { success: false, error: { code, message } } the declared envelope `marketplaceApi.ts` hand-rolled that read at seven call sites, in four different ways. The spread was not harmless in either direction: - TWO read `payload?.error` bare with no nested fallback. When the producer converts, those receive an OBJECT where the surrounding type declares `error?: string`. An object handed to a toast as a React child crashes the page — objectstack#3913's exact symptom, which the actions route already carries `interpretActionResponse` to prevent. Nothing typed catches it: these payloads are `any`. - TWO read only the nested form, so against a server shipping TODAY they find nothing and degrade a real explanation into a bare status code. So this is not only preparation. Two of the four defects are live: those sites are swallowing messages users should be seeing right now. One `readApiError(payload, res)` replaces all seven. It reads nested first, then `fail()`'s bare string, then a top-level `message`, then `statusText`, and it guarantees a STRING back — an unexpected shape degrades to the code rather than travelling onward as an object, which is the property the two bare sites needed. The `installPackage` site keeps its own `innerFailed` branch: that one reads a different object (the HTTP-200-with-`data.success: false` shape), so only its outer read is shared. Tests pin all four dialects reading the same, the sibling-`code` form (`failWithCode`), and that an unexpected shape can never leave as an object. This is the consumer half of cloud#944's conversion order, and the reason that order exists: the producer flip is two functions in `cloud-artifact-helpers.ts`, but doing it before this lands breaks the console. Same shape as objectui#2992 teaching `useAgents` four shapes before the /ai/agents producers moved (objectstack#4053) — consumer first, then the server converts on its own schedule. Verified: 8 new tests pass, app-shell tsc clean. No behavior change against any server shipping today except the two sites that start showing their message. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZaiYNM5igzGk6JiibjShb
1 parent eddd4a1 commit 3b6cebb

2 files changed

Lines changed: 137 additions & 17 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* `readApiError` — every failure dialect `service-cloud` answers in (cloud#944).
6+
*
7+
* These routes are mid-conversion. `fail()` / `failWithCode()` emit
8+
* `{ success: false, error: '<message>' }` today; the declared envelope nests it
9+
* as `{ code, message }`. Both must read the same here, so the server side can
10+
* convert on its own schedule instead of landing in lockstep with a console
11+
* release — the same reason `useAgents` learned four shapes before the /ai/agents
12+
* producers moved (objectui#2992, objectstack#4053).
13+
*
14+
* Why a test rather than a comment: the failure is not a parse error. Two call
15+
* sites in `marketplaceApi.ts` read `payload?.error` bare, so a converted
16+
* producer hands them an OBJECT where the surrounding type declares
17+
* `error?: string`. That object reaches `toast.error()` as a React child and
18+
* crashes the page — objectstack#3913's exact symptom, which the actions route
19+
* already carries `interpretActionResponse` to prevent. Nothing about the types
20+
* catches it: the payloads are `any`.
21+
*/
22+
23+
import { describe, expect, it } from 'vitest';
24+
import { readApiError } from '../marketplaceApi';
25+
26+
const res = (status = 400, statusText = 'Bad Request') => ({ status, statusText });
27+
28+
describe('readApiError — both dialects read the same', () => {
29+
it('reads the declared envelope', () => {
30+
expect(readApiError(
31+
{ success: false, error: { code: 'ENV_NOT_FOUND', message: 'Environment not found' } },
32+
res(404, 'Not Found'),
33+
)).toEqual({ code: 'ENV_NOT_FOUND', message: 'Environment not found' });
34+
});
35+
36+
it("reads `fail()`'s bare string — what these routes send today", () => {
37+
expect(readApiError(
38+
{ success: false, error: 'environment_id is required' },
39+
res(),
40+
)).toEqual({ code: 'HTTP_400', message: 'environment_id is required' });
41+
});
42+
43+
it('agrees on the message across both — the point of one reader', () => {
44+
const nested = readApiError({ error: { code: 'X', message: 'same text' } }, res());
45+
const bare = readApiError({ error: 'same text' }, res());
46+
expect(nested.message).toBe(bare.message);
47+
});
48+
49+
it("reads `failWithCode()`'s sibling `code`", () => {
50+
// `{ success: false, error: '<message>', code }` — the third dialect, where
51+
// the code sits BESIDE `error` rather than inside it.
52+
expect(readApiError(
53+
{ success: false, error: 'Slug rename in progress', code: 'SLUG_RENAME_IN_PROGRESS' },
54+
res(503, 'Service Unavailable'),
55+
)).toEqual({ code: 'SLUG_RENAME_IN_PROGRESS', message: 'Slug rename in progress' });
56+
});
57+
58+
it('prefers the nested code over a sibling one when both are present', () => {
59+
expect(readApiError({ error: { code: 'INNER', message: 'm' }, code: 'OUTER' }, res()).code)
60+
.toBe('INNER');
61+
});
62+
});
63+
64+
describe('readApiError — never hands back a non-string message', () => {
65+
it('degrades an unexpected `error` shape to the code rather than passing the object on', () => {
66+
// The whole reason this returns a string: an object travelling onward as
67+
// `ActionResult.error` reaches a toast as a React child and crashes the page.
68+
const out = readApiError({ error: { unexpected: true } }, res(500, 'Server Error'));
69+
expect(typeof out.message).toBe('string');
70+
expect(out.message).toBe('HTTP_500');
71+
});
72+
73+
it('falls back to statusText when there is no body at all', () => {
74+
expect(readApiError(null, res(502, 'Bad Gateway')))
75+
.toEqual({ code: 'HTTP_502', message: 'Bad Gateway' });
76+
expect(readApiError({}, res(502, 'Bad Gateway')))
77+
.toEqual({ code: 'HTTP_502', message: 'Bad Gateway' });
78+
});
79+
80+
it('reads a top-level `message` — a few routes put the text there', () => {
81+
expect(readApiError({ message: 'quota exceeded' }, res(429, 'Too Many Requests')).message)
82+
.toBe('quota exceeded');
83+
});
84+
});

packages/app-shell/src/console/marketplace/marketplaceApi.ts

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,48 @@ function withEnvAuth(headers: Record<string, string>): Record<string, string> {
3838
return token ? { ...headers, Authorization: `Bearer ${token}` } : headers;
3939
}
4040

41+
/**
42+
* Read a code + human message out of a failed cloud response, whichever dialect
43+
* it arrived in.
44+
*
45+
* The routes behind this file (`service-cloud`) answer failures in two shapes,
46+
* and are mid-conversion from one to the other (cloud#944):
47+
*
48+
* { success: false, error: '<message>' } today, via `fail()`
49+
* { success: false, error: { code, message } } the declared envelope
50+
*
51+
* Seven call sites in this file each hand-rolled their own read of that, in
52+
* four different ways, and the spread was not harmless:
53+
*
54+
* - two read `payload?.error` bare, with no nested fallback. Once the
55+
* producer converts, those receive an OBJECT where the surrounding type
56+
* declares `error?: string` — and an object handed to a toast as a React
57+
* child crashes the page. That is objectstack#3913's exact symptom, which
58+
* `interpretActionResponse` already exists to prevent on the actions route.
59+
* - two read only the nested form, so against a server shipping TODAY they
60+
* find nothing and degrade a real explanation into a bare status code.
61+
*
62+
* So this is not only preparation for the conversion — it fixes messages that
63+
* are already being swallowed. One function, so the next reader has one place
64+
* to look and the next route has nothing to hand-roll.
65+
*
66+
* The `typeof … === 'string'` guard is what makes it safe to hand the result
67+
* straight to a `string` field: an unexpected shape degrades to the code rather
68+
* than travelling onward as an object.
69+
*/
70+
export function readApiError(
71+
payload: any,
72+
res: { status: number; statusText: string },
73+
): { code: string; message: string } {
74+
const code = payload?.error?.code ?? payload?.code ?? `HTTP_${res.status}`;
75+
const raw =
76+
payload?.error?.message // the declared envelope
77+
?? payload?.error // `fail()`'s bare string
78+
?? payload?.message // a few routes put the text here
79+
?? res.statusText;
80+
return { code: String(code), message: typeof raw === 'string' ? raw : String(code) };
81+
}
82+
4183
/**
4284
* Per-locale overrides for translatable package fields. Mirrors
4385
* `PackageTranslation` from @objectstack/spec/cloud — duplicated here
@@ -143,8 +185,7 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
143185
let payload: any = null;
144186
try { payload = await res.json(); } catch { /* empty body */ }
145187
if (!res.ok) {
146-
const code = payload?.error?.code ?? payload?.code ?? `HTTP_${res.status}`;
147-
const message = payload?.error?.message ?? payload?.error ?? res.statusText;
188+
const { code, message } = readApiError(payload, res);
148189
const err = new Error(typeof message === 'string' ? message : `${code}`);
149190
(err as any).code = code;
150191
(err as any).status = res.status;
@@ -221,8 +262,7 @@ export async function installPackage(input: {
221262
let payload: any = null;
222263
try { payload = await res.json(); } catch { /* empty */ }
223264
if (!res.ok || payload?.success === false) {
224-
const code = payload?.error?.code ?? payload?.code ?? `HTTP_${res.status}`;
225-
const message = payload?.error?.message ?? payload?.error ?? payload?.message ?? res.statusText;
265+
const { code, message } = readApiError(payload, res);
226266
const err = new Error(typeof message === 'string' ? message : `${code}`);
227267
(err as any).code = code;
228268
(err as any).status = res.status;
@@ -255,13 +295,11 @@ export async function installPackage(input: {
255295
const inner = payload?.data;
256296
const innerFailed = !!inner && typeof inner === 'object' && inner.success === false;
257297
if (!res.ok || innerFailed) {
258-
const code = payload?.code ?? payload?.error?.code ?? `HTTP_${res.status}`;
259-
// `error` is a nested `{code, message}` object on the current wire and a
260-
// plain string on the older one — read the message out of both before
261-
// falling back, or a real explanation degrades into a bare status code.
262-
const message = innerFailed
263-
? (inner.error ?? res.statusText)
264-
: (payload?.error?.message ?? payload?.error ?? payload?.message ?? res.statusText);
298+
// The inner branch reads a DIFFERENT object — the 200-with-`data.success:
299+
// false` shape above — so it stays hand-read; only the outer one is shared.
300+
const outer = readApiError(payload, res);
301+
const code = payload?.code ?? outer.code;
302+
const message = innerFailed ? (inner.error ?? res.statusText) : outer.message;
265303
const err = new Error(typeof message === 'string' ? message : `${code}`);
266304
(err as any).code = code;
267305
(err as any).status = res.status;
@@ -488,7 +526,7 @@ export async function reseedSampleData(installationId: string): Promise<{ ok: bo
488526
});
489527
const payload: any = await res.json().catch(() => ({}));
490528
if (!res.ok || payload?.success === false) {
491-
return { ok: false, error: payload?.error || `HTTP ${res.status}` };
529+
return { ok: false, error: readApiError(payload, res).message };
492530
}
493531
return { ok: true };
494532
} catch (err: any) {
@@ -509,7 +547,7 @@ export async function purgeSampleData(installationId: string): Promise<{ ok: boo
509547
});
510548
const payload: any = await res.json().catch(() => ({}));
511549
if (!res.ok || payload?.success === false) {
512-
return { ok: false, error: payload?.error || `HTTP ${res.status}` };
550+
return { ok: false, error: readApiError(payload, res).message };
513551
}
514552
return { ok: true, deleted: Number(payload?.data?.deleted ?? 0) };
515553
} catch (err: any) {
@@ -604,8 +642,7 @@ export async function installLocal(input: {
604642
let payload: any = null;
605643
try { payload = await res.json(); } catch { /* empty */ }
606644
if (!res.ok) {
607-
const code = payload?.error?.code ?? `HTTP_${res.status}`;
608-
const message = payload?.error?.message ?? res.statusText;
645+
const { code, message } = readApiError(payload, res);
609646
const err = new Error(typeof message === 'string' ? message : `${code}`);
610647
(err as any).code = code;
611648
(err as any).status = res.status;
@@ -683,8 +720,7 @@ export async function uninstallLocal(manifestId: string): Promise<void> {
683720
});
684721
if (!res.ok) {
685722
const payload: any = await res.json().catch(() => ({}));
686-
const message = payload?.error?.message ?? res.statusText;
687-
throw new Error(typeof message === 'string' ? message : `HTTP_${res.status}`);
723+
throw new Error(readApiError(payload, res).message);
688724
}
689725
}
690726

0 commit comments

Comments
 (0)