Skip to content

Commit 6e141bc

Browse files
authored
fix(actions): an action that CRASHED is a 500, not a 200 reporting success:false (#3913 follow-up) (#3951)
#3937 settled that a failed action reports in the payload at HTTP 200 — "an action that fails is a normal outcome, not a transport error". That is a statement about the action REJECTING: a business rule saying no. The same exit was also covering a third case it never argued for. A TypeError in a handler, a driver blowing up, a sandbox timeout are not outcomes the action chose to report — they are the server failing to produce one. Serving them as 200 hid every handler crash from the layers that exist to catch server faults: gateway error rates, retry and circuit-breaker policy, APM auto-capture, alerting, fetch().ok. Those are 500 now, through the same errorFromThrown exit every other domain catch has used since #3925 — which also puts a driver dump behind the internal-error-leak sanitiser (#3867) instead of letting it reach the client verbatim in a 200 body. Nothing #3937 put in the payload moves. Rejection and crash are told apart by the error's NAME, the signal @objectstack/rest already uses on this exact distinction: a plain Error, a SandboxError carrying innerMessage, anything with code/fields, and a ValidationError by name are rejections; TypeError / ReferenceError / a driver's own class, and a SandboxError with no innerMessage (timeout, capability denial) are crashes. A throw with no name at all is not confidently a fault and keeps its 200 — deliberately the narrow direction. Also in the same exit: an error carrying its own status/statusCode (a plugin's FORBIDDEN with status 403) is served with it rather than buried in a 200 payload. Record ValidationErrors deliberately carry no .status, so #3937's cases never reach that branch. Documented in api/error-catalog.mdx (new Action Errors section with the full status table and the two-check pattern a raw fetch caller needs) and ui/actions.mdx.
1 parent a95e545 commit 6e141bc

5 files changed

Lines changed: 386 additions & 3 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
fix(actions): an action that CRASHED is a 500, not a 200 reporting success:false (#3913 follow-up)
6+
7+
#3937 settled that a failed action reports in the payload at HTTP 200 — "an
8+
action that fails is a normal outcome, not a transport error". That is a
9+
statement about the action **rejecting**: a business rule saying no. The same
10+
exit was also covering a third case it never argued for.
11+
12+
A `TypeError` in a handler, a driver blowing up, a sandbox timeout — those are
13+
not outcomes the action chose to report, they are the server failing to produce
14+
one. Serving them as 200 hid **every handler crash** from the layers that exist
15+
to catch server faults: gateway error rates, retry and circuit-breaker policy,
16+
APM auto-capture, alerting, `fetch().ok`. For a platform whose main extension
17+
surface is customer-authored script bodies, "customer action bodies are
18+
throwing" had no signal short of body-parsing at every hop.
19+
20+
Those are **500** now, through the same `errorFromThrown` exit every other
21+
domain catch has used since #3925 — which also means a driver dump finally goes
22+
through the internal-error-leak sanitiser (#3867) instead of reaching the client
23+
verbatim in a 200 body.
24+
25+
**Nothing #3937 put in the payload moves.** A rejection and a crash are told
26+
apart by the error's NAME, the signal `@objectstack/rest` already uses on this
27+
exact distinction ("non-default names (`TypeError: …`) [] signal a genuine
28+
script bug rather than a deliberately thrown business rule"):
29+
30+
| Thrown | Verdict | Wire |
31+
|:---|:---|:---|
32+
| `new Error(msg)` — a registered handler rejecting | rejection | 200 + payload |
33+
| `SandboxError` with `innerMessage` — a body's deliberate throw | rejection | 200 + payload |
34+
| Anything carrying `code` / `fields`, or a `ValidationError` by name | rejection | 200 + payload |
35+
| A throw with no `name` at all | *not confidently a fault* | 200 + payload |
36+
| `TypeError` / `ReferenceError` / `SqliteError` / a driver's class | crash | **500** |
37+
| `SandboxError` with no `innerMessage` — timeout, capability denial | crash | **500** |
38+
39+
Deliberately the narrow direction: only what is *certainly* a fault moves, and
40+
everything uncertain keeps the 200 it has today.
41+
42+
One related fix in the same exit: an error carrying its own `status` /
43+
`statusCode` (a plugin's `FORBIDDEN` with `status: 403`) is now served with it
44+
rather than buried in a 200 payload — that status was the one thing the thrower
45+
was unambiguous about. Record `ValidationError`s deliberately carry no
46+
`.status`, so #3937's cases never reach that branch.
47+
48+
Documented in `api/error-catalog.mdx` (new **Action Errors** section with the
49+
full status table and the two-check pattern a raw `fetch` caller needs) and
50+
`ui/actions.mdx`.

content/docs/api/error-catalog.mdx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,50 @@ ObjectStack uses a structured error system with **9 error categories** and **51
349349

350350
---
351351

352+
## Action Errors (`/api/v1/actions`)
353+
354+
<Callout type="warn">
355+
**`/actions` is the one route where a failure is not always a non-2xx.** An
356+
action that runs and *rejects* is a business outcome, so it is reported inside
357+
the payload at HTTP 200. Only failures where no handler produced that outcome
358+
carry a status. Branch on both.
359+
</Callout>
360+
361+
| What happened | HTTP | Body |
362+
|:---|:---:|:---|
363+
| Action ran, returned | **200** | `{ success: true, data: { success: true, data } }` |
364+
| Action ran, **rejected** (business rule, validation) | **200** | `{ success: true, data: { success: false, error, code?, fields? } }` |
365+
| No such action registered | **404** | `{ success: false, error: { message, code } }` |
366+
| Caller lacks `requiredPermissions` | **403** | `{ success: false, error: { message, code } }` |
367+
| Type has no server dispatch (`url`/`modal`/`form`/`api`), or a param-contract violation | **400** | `{ success: false, error: { message, code } }` |
368+
| Data engine or automation service unavailable | **503** | `{ success: false, error: { message, code } }` |
369+
| Handler **crashed** (`TypeError`, driver error, sandbox timeout) | **500** | `{ success: false, error: { message, code } }` |
370+
371+
A rejection and a crash are told apart by the thrown error: a plain
372+
`throw new Error(msg)`, a sandboxed body's deliberate throw, or a
373+
`ValidationError` is a rejection; a `TypeError` / `ReferenceError` / a driver's
374+
own error class is a crash. An error carrying its own `status` is served with
375+
it.
376+
377+
```typescript
378+
const res = await fetch(`/api/v1/actions/${object}/${action}`, { /**/ });
379+
const json = await res.json();
380+
381+
// BOTH checks are required — neither one alone is sufficient.
382+
if (!res.ok) throw new Error(json.error?.message); // never dispatched, or crashed
383+
if (json.data?.success === false) showToast(json.data.error); // ran and rejected
384+
```
385+
386+
Using `@objectstack/client` removes the footgun: `client.actions.invoke()`
387+
folds both shapes into one result and never throws.
388+
389+
```typescript
390+
const res = await client.actions.invoke(object, action, { recordId, params });
391+
if (!res.success) showToast(res.error);
392+
```
393+
394+
---
395+
352396
## Error Response Structure
353397

354398
Every error response follows the `EnhancedApiError` schema:

content/docs/ui/actions.mdx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,23 @@ curl -b cookies.txt -X POST \
232232
# → 200 { "success": true, "data": { "success": true, "data": ... } }
233233
```
234234

235-
Failures split by **whether a handler ran**:
235+
Failures split three ways:
236236

237237
- **It ran and rejected** — HTTP **200**, `data: { "success": false, "error",
238-
"code"?, "fields"? }`. An action that fails is a normal business outcome, not
239-
a transport error, so it rides the payload. Always branch on `data.success`.
238+
"code"?, "fields"? }`. An action that says no is a normal business outcome,
239+
not a transport error, so it rides the payload. Always branch on
240+
`data.success`.
240241
- **It never dispatched** — a real status: **404** no such action, **403**
241242
denied, **400** wrong action type or a param-contract violation, **503**
242243
unavailable — with `{ "success": false, "error": { "message", "code" } }`.
244+
- **It crashed** — **500**. A `TypeError` in your handler, a driver error or a
245+
sandbox timeout is not an outcome your action chose to report, so it is a
246+
server fault and shows up as one in logs, alerting and gateway error rates.
247+
A deliberate `throw new Error('')` is a rejection, not a crash.
248+
249+
Full table in the [error catalog](/docs/api/error-catalog). The
250+
[client SDK](/docs/api/client-sdk) folds all of it into one
251+
`{ success, data?, error? }` result.
243252
244253
Global (object-less) actions post to `/api/v1/actions/global/:action`, or to
245254
`/api/v1/actions//:action` with the object segment left empty. For credentials,
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3913 follow-up — an action that CRASHED is not an action that REJECTED.
5+
*
6+
* #3937 settled that a failed action reports in the payload at HTTP 200: "an
7+
* action that fails is a normal outcome, not a transport error". That is a
8+
* statement about the action **rejecting** — a business rule saying no. It was
9+
* also covering a third case it never argued for: a `TypeError` in a handler,
10+
* a driver blowing up, a sandbox timeout. Those are not outcomes the action
11+
* chose to report; they are the server failing to produce one, and serving
12+
* them as 200 hid every handler crash from gateway error rates, retry policy,
13+
* APM auto-capture and alerting — the platform had no signal for "customer
14+
* action bodies are throwing" short of body-parsing at every hop.
15+
*
16+
* The discriminator is the error's NAME, the same signal `@objectstack/rest`
17+
* already uses on this distinction ("non-default names (`TypeError: …`) […]
18+
* signal a genuine script bug rather than a deliberately thrown business
19+
* rule"). This file pins BOTH sides, because the whole risk of the change is
20+
* over-reach: everything #3937 put in the payload must stay there.
21+
*/
22+
23+
import { describe, it, expect, vi } from 'vitest';
24+
25+
import { HttpDispatcher } from '../http-dispatcher.js';
26+
import { SandboxError } from '../sandbox/quickjs-runner.js';
27+
28+
const scriptAction = {
29+
name: 'submit_signoff',
30+
objectName: 'crm_invoice',
31+
type: 'script',
32+
body: { language: 'js', source: 'return 1;', capabilities: ['api.write'] },
33+
};
34+
35+
/** A dispatcher whose action handler rejects with `thrown`. */
36+
function makeDispatcher(thrown: unknown) {
37+
const objectDef = { name: 'crm_invoice', actions: [scriptAction] };
38+
const ql: any = {
39+
executeAction: vi.fn(async () => { throw thrown; }),
40+
getSchema: (n: string) => (n === objectDef.name ? objectDef : undefined),
41+
registry: { getObject: (n: string) => (n === objectDef.name ? objectDef : undefined), getItem: () => undefined },
42+
find: vi.fn(async () => [{ id: 'inv_1', status: 'draft' }]),
43+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
44+
};
45+
const metadata: any = {
46+
load: vi.fn(async () => null),
47+
listObjects: vi.fn(async () => [objectDef]),
48+
getObject: vi.fn(async () => objectDef),
49+
};
50+
const kernel: any = {
51+
context: {
52+
getService: (n: string) =>
53+
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : null,
54+
},
55+
};
56+
return new HttpDispatcher(kernel);
57+
}
58+
59+
async function invoke(thrown: unknown) {
60+
const res: any = await makeDispatcher(thrown).handleActions(
61+
'/crm_invoice/submit_signoff/inv_1',
62+
'POST',
63+
{},
64+
{ request: {}, environmentId: 'platform', executionContext: { userId: 'u1', systemPermissions: [] } } as any,
65+
);
66+
return res.response;
67+
}
68+
69+
describe('a deliberate REJECTION keeps the payload at HTTP 200', () => {
70+
it('a plain `throw new Error(msg)` from a registered handler', async () => {
71+
// The shape user code registered via `engine.registerAction` uses to
72+
// reject. `name === 'Error'` is what marks it deliberate — this is the
73+
// case a naive "no sandbox marker ⇒ fault" rule would have broken.
74+
const response = await invoke(new Error('Lead is already converted'));
75+
76+
expect(response.status).toBe(200);
77+
expect(response.body.data).toEqual({ success: false, error: 'Lead is already converted' });
78+
});
79+
80+
it('a sandboxed body that threw on purpose (SandboxError.innerMessage)', async () => {
81+
const response = await invoke(
82+
new SandboxError("action 'submit_signoff' threw: Contact has no phone", 'Contact has no phone'),
83+
);
84+
85+
expect(response.status).toBe(200);
86+
expect(response.body.data).toEqual({ success: false, error: 'Contact has no phone' });
87+
});
88+
89+
it('a record validation failure, code + fields intact (#3937)', async () => {
90+
const fields = [{ field: 'issued_on', code: 'required', message: 'issued_on is required' }];
91+
const response = await invoke(
92+
new SandboxError(
93+
"action 'submit_signoff' threw: ValidationError: issued_on is required",
94+
'ValidationError: issued_on is required',
95+
{ code: 'VALIDATION_FAILED', fields },
96+
),
97+
);
98+
99+
expect(response.status).toBe(200);
100+
expect(response.body.data).toMatchObject({ success: false, code: 'VALIDATION_FAILED', fields });
101+
});
102+
103+
it('a bare ValidationError whose fields were stripped in transit', async () => {
104+
// Recognised by NAME via `validationFailureDetails` — the same predicate
105+
// the dispatcher's error exits use — so losing `fields` downgrades the
106+
// detail, never the classification.
107+
const err: any = new Error('issued_on is required');
108+
err.name = 'ValidationError';
109+
const response = await invoke(err);
110+
111+
expect(response.status).toBe(200);
112+
expect(response.body.data.success).toBe(false);
113+
});
114+
115+
it('a flow that ran and rejected', async () => {
116+
// `dispatchFlowAction` throws a plain Error, so it lands on the
117+
// deliberate side with no special-casing.
118+
const response = await invoke(new Error("Flow 'convert_wizard' failed: lead already converted"));
119+
120+
expect(response.status).toBe(200);
121+
expect(response.body.data.success).toBe(false);
122+
expect(response.body.data.error).toMatch(/lead already converted/);
123+
});
124+
125+
it('an unrecognisable throw — no name at all — keeps the status quo', async () => {
126+
// The change only moves what it is SURE about. A thrown string or a
127+
// bare object is not confidently a fault, so it keeps its 200.
128+
const response = await invoke('something odd');
129+
130+
expect(response.status).toBe(200);
131+
expect(response.body.data.success).toBe(false);
132+
});
133+
});
134+
135+
describe('an unexpected FAULT is a 500', () => {
136+
it('a TypeError from a buggy handler', async () => {
137+
const response = await invoke(new TypeError("Cannot read properties of undefined (reading 'id')"));
138+
139+
expect(response.status).toBe(500);
140+
expect(response.body.success).toBe(false);
141+
// No `{success:true, data:{...}}` wrapper — this is the dispatcher's
142+
// error exit, so monitoring sees a 5xx.
143+
expect(response.body.data).toBeUndefined();
144+
});
145+
146+
it('a ReferenceError from a buggy handler', async () => {
147+
const response = await invoke(new ReferenceError('x is not defined'));
148+
149+
expect(response.status).toBe(500);
150+
});
151+
152+
it("a driver's own error class", async () => {
153+
const err: any = new Error('no such table: crm_invoice');
154+
err.name = 'SqliteError';
155+
const response = await invoke(err);
156+
157+
expect(response.status).toBe(500);
158+
});
159+
160+
it('a driver dump the leak heuristic recognises is sanitised', async () => {
161+
// Reaching the 5xx exit also puts these messages behind
162+
// `looksLikeInternalErrorLeak` (#3867) — which the 200 payload never
163+
// consulted, so a driver dump used to reach the client verbatim.
164+
const err: any = new Error('UNIQUE constraint failed: crm_invoice.number');
165+
err.name = 'SqliteError';
166+
const response = await invoke(err);
167+
168+
expect(response.status).toBe(500);
169+
expect(response.body.error.message).toBe('Internal server error');
170+
});
171+
172+
it("the sandbox's OWN internal errors — a timeout, a capability denial", async () => {
173+
// A `SandboxError` with no `innerMessage` is the sandbox failing, not
174+
// user code rejecting: a timeout, a denied capability, a marshalling
175+
// failure. Precisely the class an operator wants to alert on, and
176+
// precisely what a 200 made invisible.
177+
const response = await invoke(new SandboxError('action timed out after 5000ms'));
178+
179+
expect(response.status).toBe(500);
180+
});
181+
182+
it('still honours an error that carries its own status', async () => {
183+
// `errorFromThrown` reads `.status` first, so a hand-thrown 4xx is not
184+
// flattened into the 500 fallback.
185+
const err: any = new TypeError('Not allowed');
186+
err.status = 403;
187+
err.code = 'FORBIDDEN';
188+
const response = await invoke(err);
189+
190+
expect(response.status).toBe(403);
191+
});
192+
193+
it('honours an explicit status even on an otherwise-deliberate error', async () => {
194+
// A plugin's `FORBIDDEN` is a plain Error carrying `status: 403` — the
195+
// "deliberate" side by name, but burying an explicit 403 in a 200
196+
// payload would discard the one thing the thrower was unambiguous
197+
// about, so `.status` is checked first.
198+
const err: any = new Error('Record is outside your sharing scope');
199+
err.status = 403;
200+
err.code = 'FORBIDDEN';
201+
const response = await invoke(err);
202+
203+
expect(response.status).toBe(403);
204+
expect(response.body.error.message).toBe('Record is outside your sharing scope');
205+
expect(response.body.error.details?.code).toBe('FORBIDDEN');
206+
});
207+
});

0 commit comments

Comments
 (0)