Skip to content

Commit 195ad76

Browse files
authored
fix(actions)!: failures speak HTTP — rejections are 400, success is a single wrap (#3962) (#3969)
Fixes #3962 — the platform decision classifying the 200-on-failure wire as a bug, not a contract. The 200-with-inner-envelope shape was never designed: no ADR or doc specified it, it originated as the catch block reusing deps.success(), and /actions was the only route of 12 that double-wrapped. Five defects traced back to that one extra layer. Contract now, identical to /data: - ran, returned → 200 {success:true, data: <handler return value>} (single wrap) - ran, rejected → 400, semantic code on error.code (VALIDATION_FAILED with fields[] in details; FLOW_FAILED for a rejected flow) - never dispatched → 404 / 403 / 400 / 503 (unchanged, #3930/#3951) - crashed → 500 (unchanged, #3951; name-based discriminator now selects 400 vs 500) actions-validation-envelope.test.ts pinned the 200 "so flipping it later is a conscious, documented break"; #3962 is that decision and the flipped test cites it. Integrates ADR-0110 (#3958/#3987 — name identity, undeclared refusal with no opt-out) and #3971 (semantic error.code, details.code promotion) from main. client.actions.invoke/invokeGlobal still never throw: every failure status folds into {success:false, error}, success reads the single wrap, and a narrow legacy heuristic (boolean success, no foreign keys) keeps a current SDK correct against pre-#3962 servers. Migration (raw-HTTP callers): branch on the status; on 200, data is the handler's return value directly. SDK callers need no change.
1 parent 32ccb23 commit 195ad76

13 files changed

Lines changed: 264 additions & 205 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/runtime": major
3+
"@objectstack/client": minor
4+
---
5+
6+
fix(actions)!: failures speak HTTP — business rejections are 400, success is a single wrap (#3962)
7+
8+
**BREAKING (raw-HTTP callers of `POST /api/v1/actions/...` only).** The
9+
200-with-inner-envelope wire was never a designed contract: no ADR or doc ever
10+
specified it, it originated as the route's catch block reusing
11+
`deps.success()`, and `/actions` was the only route of 12 that double-wrapped.
12+
#3962 classifies it as a bug. Five defects traced back to that one extra layer
13+
(the console's green toast on failed actions, `redirectUrl` never firing, a
14+
marketplace install reported as installed when it failed, the client-envelope
15+
divergence #3927 papered over, and crashes invisible to monitoring).
16+
17+
The contract now, identical to `/data`:
18+
19+
| Outcome | HTTP | Body |
20+
|:---|:---:|:---|
21+
| Ran, returned | **200** | `{success: true, data: <handler return value>}` — single wrap |
22+
| Ran, rejected (business rule / validation) | **400** | `{success: false, error: {message, code, details: {code?, fields?}}}` |
23+
| Never dispatched (unknown / denied / wrong type / unavailable) | 404 / 403 / 400 / 503 | unchanged (#3930/#3951) |
24+
| Crashed (`TypeError`, driver class, sandbox timeout) | **500** | unchanged (#3951) |
25+
26+
A validation rejection carries `details.code: 'VALIDATION_FAILED'` and
27+
`details.fields[]` — the exact payload #3937 fought for, now on the same wire
28+
shape `/data` has always used, which `@objectstack/client` normalizes to
29+
`err.code` / `err.fields` (#3927). A rejected flow is a 400 with
30+
`details.code: 'FLOW_FAILED'`. The crash-vs-rejection discriminator (#3951,
31+
error `name`) now selects 400 vs 500.
32+
33+
`client.actions.invoke` / `invokeGlobal` still never throw: they fold every
34+
failure status into `{success: false, error}`, read the single wrap on
35+
success, and keep a NARROW legacy heuristic so a current SDK talking to a
36+
pre-#3962 server still folds the old double-wrapped 200s correctly.
37+
38+
**Migration for raw-HTTP third parties:** branch on the HTTP status — a
39+
non-2xx is the failure, `error.message` / `error.details` carry the detail; on
40+
a 200, `data` is the handler's return value directly (one level less than
41+
before). Callers using `@objectstack/client` need no change.

content/docs/api/client-sdk.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,9 @@ await client.automation.getScreen('convert_lead', runId);
394394
// (engine.registerAction). The result is `{ success, data | error }` — a
395395
// failure comes back as `success: false` + `error` instead of a thrown
396396
// exception, so it can be surfaced as a toast. This surface is the one place
397-
// a non-2xx does NOT throw: a handler that rejects answers 200 with the inner
398-
// envelope, a request that never dispatched answers 404 / 403 / 400 / 503,
399-
// and the SDK folds both into the same result.
397+
// a non-2xx does NOT throw: failures speak HTTP (400 rejection, 404 / 403 /
398+
// 503 dispatch failures, 500 crash) and the SDK folds them into the result;
399+
// on success, `data` is the handler's return value directly.
400400
const res = await client.actions.invoke('crm_lead', 'convert', {
401401
recordId,
402402
params: { create_opportunity: true },

content/docs/api/error-catalog.mdx

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -375,17 +375,14 @@ an environment scope (no `X-Environment-Id` header and no hostname mapping).
375375

376376
## Action Errors (`/api/v1/actions`)
377377

378-
<Callout type="warn">
379-
**`/actions` is the one route where a failure is not always a non-2xx.** An
380-
action that runs and *rejects* is a business outcome, so it is reported inside
381-
the payload at HTTP 200. Only failures where no handler produced that outcome
382-
carry a status. Branch on both.
383-
</Callout>
378+
Since #3962 `/actions` failures speak HTTP like every other route — the status
379+
code is the failure signal, and on success `data` is the handler's return
380+
value directly (single wrap).
384381

385382
| What happened | HTTP | Body |
386383
|:---|:---:|:---|
387-
| Action ran, returned | **200** | `{ success: true, data: { success: true, data } }` |
388-
| Action ran, **rejected** (business rule, validation) | **200** | `{ success: true, data: { success: false, error, code?, fields? } }` |
384+
| Action ran, returned | **200** | `{ success: true, data: <handler return value> }` |
385+
| Action ran, **rejected** (business rule, validation) | **400** | `{ success: false, error: { message, code, details: { code?, fields? } } }` |
389386
| No such action registered | **404** | `{ success: false, error: { message, code } }` |
390387
| Caller lacks `requiredPermissions` | **403** | `{ success: false, error: { message, code } }` |
391388
| Type has no server dispatch (`url`/`modal`/`form`/`api`), or a param-contract violation | **400** | `{ success: false, error: { message, code } }` |
@@ -394,21 +391,20 @@ carry a status. Branch on both.
394391

395392
A rejection and a crash are told apart by the thrown error: a plain
396393
`throw new Error(msg)`, a sandboxed body's deliberate throw, or a
397-
`ValidationError` is a rejection; a `TypeError` / `ReferenceError` / a driver's
398-
own error class is a crash. An error carrying its own `status` is served with
399-
it.
394+
`ValidationError` is a rejection (400); a `TypeError` / `ReferenceError` / a
395+
driver's own error class is a crash (500). An error carrying its own `status`
396+
is served with it. A validation rejection carries `details.code:
397+
'VALIDATION_FAILED'` and `details.fields[]`, identical to `/data`.
400398

401399
```typescript
402400
const res = await fetch(`/api/v1/actions/${object}/${action}`, { /**/ });
403401
const json = await res.json();
404-
405-
// BOTH checks are required — neither one alone is sufficient.
406-
if (!res.ok) throw new Error(json.error?.message); // never dispatched, or crashed
407-
if (json.data?.success === false) showToast(json.data.error); // ran and rejected
402+
if (!res.ok) showToast(json.error?.message); // the status IS the failure signal
403+
else use(json.data); // the handler's return value
408404
```
409405

410-
Using `@objectstack/client` removes the footgun: `client.actions.invoke()`
411-
folds both shapes into one result and never throws.
406+
`client.actions.invoke()` folds this (and the pre-#3962 legacy 200 envelope)
407+
into one `{ success, data?, error? }` result and never throws.
412408

413409
```typescript
414410
const res = await client.actions.invoke(object, action, { recordId, params });

content/docs/ui/actions.mdx

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ curl -b cookies.txt -X POST \
229229
https://your-app.example.com/api/v1/actions/todo_task/complete_task \
230230
-H "Content-Type: application/json" \
231231
-d '{ "recordId": "rec_123", "params": {} }'
232-
# → 200 { "success": true, "data": { "success": true, "data": ... } }
232+
# → 200 { "success": true, "data": <your handler's return value> }
233233
```
234234

235235
The URL names the action by its **`name`**, never by `target`. `target` binds
@@ -239,19 +239,16 @@ the server resolves your declaration by name and derives the handler key from
239239
it. Rename the underlying function freely; as long as the declaration's
240240
`target` follows, the public URL is unchanged.
241241

242-
Failures split three ways:
243-
244-
- **It ran and rejected** — HTTP **200**, `data: { "success": false, "error",
245-
"code"?, "fields"? }`. An action that says no is a normal business outcome,
246-
not a transport error, so it rides the payload. Always branch on
247-
`data.success`.
248-
- **It never dispatched** — a real status: **404** no such action, **403**
249-
denied, **400** wrong action type or a param-contract violation, **503**
250-
unavailable — with `{ "success": false, "error": { "message", "code" } }`.
251-
- **It crashed** — **500**. A `TypeError` in your handler, a driver error or a
252-
sandbox timeout is not an outcome your action chose to report, so it is a
253-
server fault and shows up as one in logs, alerting and gateway error rates.
254-
A deliberate `throw new Error('')` is a rejection, not a crash.
242+
Failures speak HTTP — the status code is the signal (#3962):
243+
244+
- **400** — the action ran and **rejected** (a business rule said no, or
245+
validation failed — then with `error.details.fields[]` to anchor the input).
246+
- **404 / 403 / 503** — it never dispatched: no such action, denied, service
247+
unavailable. A `url`/`modal`/`form`/`api` type with no server dispatch is
248+
also a 400.
249+
- **500** — it **crashed**: a `TypeError` in your handler, a driver error, a
250+
sandbox timeout. A deliberate `throw new Error('…')` is a 400 rejection,
251+
not a crash.
255252

256253
Full table in the [error catalog](/docs/api/error-catalog). The
257254
[client SDK](/docs/api/client-sdk) folds all of it into one

packages/client/src/actions-surface.test.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('client.actions', () => {
2828
it('invoke POSTs to /api/v1/actions/:object/:action with recordId + params in the body', async () => {
2929
const { client, fetchMock } = createMockClient({
3030
success: true,
31-
data: { success: true, data: { converted: true } },
31+
data: { converted: true }, // #3962: single wrap — data IS the handler value
3232
});
3333

3434
const result = await client.actions.invoke('crm_lead', 'convert', {
@@ -71,11 +71,10 @@ describe('client.actions', () => {
7171
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: { dryRun: true } });
7272
});
7373

74-
it('surfaces the handler business failure without throwing (inner success:false)', async () => {
75-
// A handler that RAN and rejected is a business outcome, reported in
76-
// the payload at HTTP 200 — unchanged by #3913, which only moved the
77-
// DISPATCH failures to a status. The inner envelope is authoritative
78-
// on a 2xx.
74+
it('still folds a PRE-#3962 server\'s 200-wrapped failure (legacy inner envelope)', async () => {
75+
// Servers older than #3962 reported a rejection as HTTP 200 with the
76+
// inner `{success:false, error}`. A current SDK still talks to them,
77+
// so the narrowly-detected legacy shape stays authoritative on a 2xx.
7978
const { client } = createMockClient({
8079
success: true,
8180
data: { success: false, error: 'Lead is already converted' },
@@ -86,12 +85,9 @@ describe('client.actions', () => {
8685
});
8786

8887
it('reports a dispatch failure (404) as the same non-throwing envelope (#3913)', async () => {
89-
// An unregistered action never dispatched, so the server answers a real
90-
// status with `{success:false, error:{message,code}}`. `client.fetch`
91-
// throws on any non-2xx, but this surface's contract is to REPORT, not
92-
// throw — callers toast `result.error` rather than wrapping every call
93-
// in a try/catch, and that must not change just because these routes
94-
// gained a status.
88+
// `client.fetch` throws on any non-2xx, but this surface's contract is
89+
// to REPORT, not throw — callers toast `result.error` rather than
90+
// wrapping every call in a try/catch.
9591
const { client } = createMockClient(
9692
{ success: false, error: { message: "Action 'log_call' on object 'global' not found", code: 404 } },
9793
404,
@@ -103,6 +99,35 @@ describe('client.actions', () => {
10399
expect(result.error).toBe("Action 'log_call' on object 'global' not found");
104100
});
105101

102+
it('reports a business rejection (400) with the message, non-throwing (#3962)', async () => {
103+
const { client } = createMockClient(
104+
{
105+
success: false,
106+
error: {
107+
message: 'ValidationError: issued_on is required',
108+
code: 400,
109+
details: { code: 'VALIDATION_FAILED', fields: [{ field: 'issued_on' }] },
110+
},
111+
},
112+
400,
113+
);
114+
const result = await client.actions.invoke('crm_invoice', 'submit_signoff', { recordId: 'inv_1' });
115+
expect(result.success).toBe(false);
116+
expect(result.error).toBe('ValidationError: issued_on is required');
117+
});
118+
119+
it('passes a handler value that merely CONTAINS success-ish keys through untouched (#3962)', async () => {
120+
// Single wrap means data is handler-owned. Only the exact legacy
121+
// envelope shape (boolean `success`, no foreign keys) is unwrapped.
122+
const { client } = createMockClient({
123+
success: true,
124+
data: { success: true, rows: 3, data: { id: 'x' }, extra: 'mine' },
125+
});
126+
const result = await client.actions.invoke('crm_lead', 'sync');
127+
expect(result.success).toBe(true);
128+
expect(result.data).toEqual({ success: true, rows: 3, data: { id: 'x' }, extra: 'mine' });
129+
});
130+
106131
it('reports a 403 denial the same way', async () => {
107132
const { client } = createMockClient(
108133
{ success: false, error: { message: 'Missing capability can_convert', code: 403 } },

packages/client/src/index.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -323,20 +323,28 @@ function actionErrorMessage(e: any): string {
323323
* Fold a 2xx `POST /api/v1/actions/...` body into the `{ success, data?,
324324
* error? }` shape `client.actions.invoke` has always returned.
325325
*
326-
* `payload` is what `unwrapResponse` produced — the INNER envelope. Two shapes
327-
* arrive on a 2xx and a caller must not have to tell them apart:
326+
* `payload` is what `unwrapResponse` produced. On a #3962 server a 2xx means
327+
* the action ran and returned, and `payload` IS the handler's return value —
328+
* every failure (rejection 400, dispatch 404/403/503, crash 500) arrives as a
329+
* non-2xx, `fetch` throws on it, and `invoke` catches. This surface does not
330+
* throw either way.
328331
*
329-
* - `{ success: true, data }` — the action ran and returned.
330-
* - `{ success: false, error, code?, fields? }` — the handler RAN and
331-
* rejected. That is a business outcome, so it rides the payload at HTTP
332-
* 200 (#3937) and the inner envelope is authoritative.
333-
*
334-
* The non-2xx case never reaches here: those are DISPATCH failures (404 / 403
335-
* / 400 / 503), `fetch` throws on them, and `invoke` catches — this surface
336-
* does not throw either way.
332+
* Servers older than #3962 answered a 2xx with the legacy INNER envelope —
333+
* `{success: true, data}` on success, `{success: false, error, code?,
334+
* fields?}` when the handler rejected. A current SDK still has to talk to
335+
* them, so that shape is detected NARROWLY (a `boolean` `success` and no keys
336+
* beyond the envelope's own) and unwrapped; anything else passes through as
337+
* the handler's data. The residual ambiguity — a handler whose own return
338+
* value is exactly envelope-shaped — is unavoidable while both servers exist
339+
* and resolves once the fleet is on #3962.
337340
*/
338341
function normalizeActionResult<T>(payload: any): { success: boolean; data?: T; error?: string } {
339-
if (payload && typeof payload === 'object' && 'success' in payload) {
342+
const ENVELOPE_KEYS = new Set(['success', 'data', 'error', 'code', 'fields']);
343+
const legacy =
344+
payload && typeof payload === 'object' && !Array.isArray(payload)
345+
&& typeof payload.success === 'boolean'
346+
&& Object.keys(payload).every((k) => ENVELOPE_KEYS.has(k));
347+
if (legacy) {
340348
return payload.success === false
341349
? { success: false, error: actionErrorMessage(payload.error) }
342350
: { success: true, data: payload.data as T };
@@ -2916,15 +2924,12 @@ export class ObjectStackClient {
29162924
* Result shape is `{ success, data | error }` and this surface does NOT
29172925
* throw — a failed business action is a toast, not a crash.
29182926
*
2919-
* Two server answers fold into that one result:
2920-
* - a handler that RAN and rejected → HTTP 200 with the inner
2921-
* `{success: false, error}` (unchanged — a business outcome is reported
2922-
* in the payload, not as a transport error);
2923-
* - a request that never DISPATCHED → a real status (404 unregistered, 403
2924-
* denied, 400 wrong action type, 503 unavailable) with
2925-
* `{success: false, error: {message, code}}`. `fetch` throws on those, so
2926-
* `invoke` catches and reports instead of propagating — #3913, where an
2927-
* unregistered action came back as a 200 and read as a success.
2927+
* Since #3962 every failure speaks HTTP: 400 rejection (with `code` /
2928+
* `fields` in `error.details`), 404 unregistered, 403 denied, 503
2929+
* unavailable, 500 crash. `fetch` throws on any non-2xx, so `invoke`
2930+
* catches and folds it into this result instead of propagating. On success,
2931+
* `data` is the handler's return value directly (single wrap); the legacy
2932+
* double-wrapped 200s of pre-#3962 servers are still recognised and folded.
29282933
*/
29292934
actions = {
29302935
/**

packages/runtime/src/action-execution.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,14 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
430430
params: seedFlowActionParams(deps, action, { objectName, record, params, recordId }),
431431
});
432432
if (result && typeof result === 'object' && 'success' in result && result.success === false) {
433-
// The flow RAN and rejected — a business outcome, so the REST route
434-
// reports it in the payload (`{success: false, error}`, HTTP 200), the
435-
// same as a script body that throws. Only a route that never dispatched
436-
// gets a status (#3913).
437-
throw new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`);
433+
// The flow RAN and rejected — a deliberate business rejection, served
434+
// as a 400 (#3962). Tagging the status/code here (rather than relying
435+
// on the route's name heuristic) keeps the semantic `FLOW_FAILED` on
436+
// the wire for callers that branch on `err.code`.
437+
const err: any = new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`);
438+
err.status = 400;
439+
err.code = 'FLOW_FAILED';
440+
throw err;
438441
}
439442
return result ?? null;
440443
}

0 commit comments

Comments
 (0)