@luckystack/api 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -14
- package/docs/api-request-lifecycle.md +280 -280
- package/docs/error-handling.md +247 -247
- package/docs/generated-types.md +264 -264
- package/docs/rate-limiting.md +188 -188
- package/docs/routing-conventions.md +164 -164
- package/package.json +66 -66
package/docs/error-handling.md
CHANGED
|
@@ -1,247 +1,247 @@
|
|
|
1
|
-
# Error Handling
|
|
2
|
-
|
|
3
|
-
> Last updated: 2026-05-20
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
`@luckystack/api` produces a strict, two-shape response envelope. Every code path through the handler — malformed input, auth failure, rate-limit rejection, handler throw, handler return — ends in either `{ status: 'success', ... }` or `{ status: 'error', errorCode, errorParams?, httpStatus, message }`. The transformation from raw failure to wire envelope flows through three layers:
|
|
8
|
-
|
|
9
|
-
1. **`tryCatch`** wraps the handler's `main(...)` call. Errors are captured (auto-forwarded to Sentry / any registered tracker with context), and the tuple `[error, result]` is returned to the pipeline. Raw exceptions never reach the client.
|
|
10
|
-
2. **`buildApiResponseEnvelope`** (socket) / **`buildNetworkError`** (HTTP) maps the raw handler result to a wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse` and routing the error fields through `normalizeErrorResponse`.
|
|
11
|
-
3. **`normalizeErrorResponse`** renders the localized `message` field from the `errorCode` + `errorParams`, using the project-registered translate function. Locale is extracted in priority order: `x-language` header → `accept-language` header → `user.language`.
|
|
12
|
-
|
|
13
|
-
This means: a handler can return `{ status: 'error', errorCode: 'organization.notFound', errorParams: [{ key: 'id', value: orgId }] }` and the framework renders `Organization "abc123" not found` (or its translation) automatically. Business-logic errors and framework-emitted errors share the same envelope shape.
|
|
14
|
-
|
|
15
|
-
Logging is uniform: every handler exception is logged via `getLogger().error(...)` with the route name and transport in the context tag, plus a stack via `tryCatch`'s capture. Dev-only warn logs are gated by `logging.devLogs`.
|
|
16
|
-
|
|
17
|
-
## API Reference
|
|
18
|
-
|
|
19
|
-
### `executeApiHandler` (internal, socket transport)
|
|
20
|
-
|
|
21
|
-
**Signature**:
|
|
22
|
-
|
|
23
|
-
```ts
|
|
24
|
-
const executeApiHandler = async (params: {
|
|
25
|
-
apiEntry: RuntimeApiEntry;
|
|
26
|
-
normalizedData: Record<string, unknown>;
|
|
27
|
-
user: SessionLayout | null;
|
|
28
|
-
functionsObject: Record<string, unknown>;
|
|
29
|
-
resolvedName: string;
|
|
30
|
-
name: string;
|
|
31
|
-
emitStream: (payload?: ApiStreamPayload) => void;
|
|
32
|
-
}): Promise<{
|
|
33
|
-
error: Error | null;
|
|
34
|
-
result: RuntimeApiResponse | undefined;
|
|
35
|
-
durationMs: number;
|
|
36
|
-
}>;
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
**Behavior**:
|
|
40
|
-
|
|
41
|
-
- Starts a Sentry span via `startSpan(name, 'api.request')`. The HTTP variant uses `'api.request.http'`.
|
|
42
|
-
- Calls `apiEntry.main({ data, user, functions, stream })` inside `tryCatch(..., undefined, { handler, api, userId, transport })`.
|
|
43
|
-
- The third argument to `tryCatch` is the auto-capture context — recorded on the registered error tracker alongside the exception.
|
|
44
|
-
- `span.end()` is called unconditionally after `main` resolves or rejects.
|
|
45
|
-
- Returns `{ error, result, durationMs }`. Caller decides what to do with each.
|
|
46
|
-
|
|
47
|
-
The HTTP transport has no extracted helper — the same flow is inlined.
|
|
48
|
-
|
|
49
|
-
### `tryCatch` (re-exported from `@luckystack/core`)
|
|
50
|
-
|
|
51
|
-
**Signature** (paraphrased):
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
async function tryCatch<T>(
|
|
55
|
-
fn: () => Promise<T>,
|
|
56
|
-
fallback?: T,
|
|
57
|
-
captureContext?: Record<string, unknown>,
|
|
58
|
-
): Promise<[Error | null, T | null]>;
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
**Behavior**: invokes `fn`; on throw, captures the error via `captureExceptionAcrossTrackers(error, captureContext)`, and returns `[error, null]`. On success, returns `[null, result]`. Used as the universal error boundary across the framework — never use raw `try / catch`.
|
|
62
|
-
|
|
63
|
-
### `buildApiResponseEnvelope` (internal, socket transport)
|
|
64
|
-
|
|
65
|
-
**Signature**:
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
const buildApiResponseEnvelope = (params: {
|
|
69
|
-
resolvedName: string;
|
|
70
|
-
error: Error | null;
|
|
71
|
-
result: RuntimeApiResponse | undefined;
|
|
72
|
-
preferredLocale: string | undefined;
|
|
73
|
-
user: SessionLayout | null;
|
|
74
|
-
}): { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown };
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
**Behavior**:
|
|
78
|
-
|
|
79
|
-
| Condition | Returns |
|
|
80
|
-
| --- | --- |
|
|
81
|
-
| `error` is non-null | Localized error envelope with `errorCode: 'api.internalServerError'`, `httpStatus: 500`. |
|
|
82
|
-
| `result` is `null` or `undefined` | Localized error envelope with `errorCode: 'api.emptyResponse'`, `httpStatus: 500`. |
|
|
83
|
-
| `result.status` is neither `'success'` nor `'error'` | Localized error envelope with `errorCode: 'api.invalidResponseStatus'`, `httpStatus: 500`. |
|
|
84
|
-
| `result.status === 'error'` | Pass through `normalizeErrorResponse({ response: result, ..., fallbackHttpStatus: defaultHttpStatusForResponse(...) })`. |
|
|
85
|
-
| `result.status === 'success'` | Spread `result`, set `httpStatus` via `defaultHttpStatusForResponse({ status: 'success', explicitHttpStatus: result.httpStatus })`. |
|
|
86
|
-
|
|
87
|
-
All four error branches and the success branch produce a fully-formed envelope ready to emit.
|
|
88
|
-
|
|
89
|
-
### `buildNetworkError` (internal closure in `handleHttpApiRequest`)
|
|
90
|
-
|
|
91
|
-
The HTTP-side equivalent. Same shape as the socket's localized-error path, but the return is typed `ApiNetworkResponse` (which has a non-optional `httpStatus: number`). Used at every HTTP early-return.
|
|
92
|
-
|
|
93
|
-
### `normalizeErrorResponse` (re-exported from `@luckystack/core`)
|
|
94
|
-
|
|
95
|
-
**Signature**:
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
function normalizeErrorResponse(params: {
|
|
99
|
-
response: { status: 'error'; errorCode?: string; errorParams?: [...]; httpStatus?: number; message?: string };
|
|
100
|
-
preferredLocale?: string | null;
|
|
101
|
-
userLanguage?: string | null;
|
|
102
|
-
fallbackHttpStatus?: number;
|
|
103
|
-
}): {
|
|
104
|
-
status: 'error';
|
|
105
|
-
httpStatus: number;
|
|
106
|
-
errorCode: string;
|
|
107
|
-
errorParams?: [...];
|
|
108
|
-
message: string;
|
|
109
|
-
};
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
**Behavior**:
|
|
113
|
-
|
|
114
|
-
- Dispatches the `preErrorNormalize` sync hook so consumers can remap `errorCode` (e.g. rewrite `auth.required` → `session.expired`).
|
|
115
|
-
- Resolves the locale: `preferredLocale ?? userLanguage ?? defaultLanguage`.
|
|
116
|
-
- Calls the registered localized normalizer (set by `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts`) with the `errorCode` + `errorParams` to render `message`.
|
|
117
|
-
- Falls back to the raw `errorCode` string when no normalizer is registered or the key is missing from the locale.
|
|
118
|
-
- Sets `httpStatus` to `response.httpStatus ?? fallbackHttpStatus ?? 500`.
|
|
119
|
-
- Dispatches the `postErrorNormalize` sync hook so consumers can rewrite the final shape (e.g. strip stack details, add a correlation ID).
|
|
120
|
-
- Returns the localized envelope.
|
|
121
|
-
|
|
122
|
-
### `defaultHttpStatusForResponse` (re-exported from `@luckystack/core`)
|
|
123
|
-
|
|
124
|
-
**Signature**:
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
function defaultHttpStatusForResponse(params: {
|
|
128
|
-
status: 'success' | 'error';
|
|
129
|
-
explicitHttpStatus?: number;
|
|
130
|
-
}): number;
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
**Behavior**: returns `explicitHttpStatus` when present; otherwise `200` for `'success'` and `500` for `'error'`. The handler's per-response `httpStatus` (set inside `main`) always wins.
|
|
134
|
-
|
|
135
|
-
### `setSentryUser` + `startSpan` (from `@luckystack/error-tracking`)
|
|
136
|
-
|
|
137
|
-
Called early in each handler:
|
|
138
|
-
|
|
139
|
-
- `setSentryUser({ id, email })` attributes every subsequent capture (within this request's call stack) to the user. Cleared with `setSentryUser(null)` between requests by virtue of being called per-request.
|
|
140
|
-
- `startSpan(name, op)` opens a tracing span. The returned object exposes `.end()` (sometimes — depends on adapter). Both transports defensively call `span?.end?.()` to support adapters that don't implement spans.
|
|
141
|
-
|
|
142
|
-
## Framework-emitted error codes
|
|
143
|
-
|
|
144
|
-
| Code | Origin | HTTP status | Notes |
|
|
145
|
-
| --- | --- | --- | --- |
|
|
146
|
-
| `api.invalidRequest` | `validateApiMessage` (socket) | 400 | Malformed socket envelope. |
|
|
147
|
-
| `api.invalidName` | HTTP entry validation | 400 | `name` missing or not a string. |
|
|
148
|
-
| `api.invalidDataObject` | HTTP entry validation | 400 | `data` missing, null, or not an object. |
|
|
149
|
-
| `routing.invalidServiceRouteName` | `parseTransportRouteName` | 400 | Route name does not match `api/<page>/<name>/v<N>`. `errorParams[0].value` echoes the original name. |
|
|
150
|
-
| `api.notFound` | runtime map lookup | 404 | Route not registered. `errorParams[0].value` echoes the name. |
|
|
151
|
-
| `auth.required` | `checkApiAuth` | 401 | `auth.login: true` and no session. |
|
|
152
|
-
| `auth.forbidden` | `validateRequest` predicate | 403 | Predicate rejected. May carry custom `errorCode` from a future custom validator. |
|
|
153
|
-
| `auth.invalidCondition` | `validateRequest` | 500 | `auth.additional[].key` is not on the session shape. Setup error. |
|
|
154
|
-
| `api.rateLimitExceeded` | `applyApiRateLimits` | 429 | `errorParams[0]` is `{ key: 'seconds', value: resetIn }`. |
|
|
155
|
-
| `api.methodNotAllowed` | HTTP method check | 405 | `errorParams[0]` is `{ key: 'method', value: expectedMethod }`. |
|
|
156
|
-
| `api.invalidInputType` | `validateInputByType` | 400 | `errorParams[0]` is `{ key: 'message', value: <validator message> }`. |
|
|
157
|
-
| `api.internalServerError` | `tryCatch` caught a throw | 500 | The original exception is on the registered tracker; not in the wire envelope. |
|
|
158
|
-
| `api.emptyResponse` | `buildApiResponseEnvelope` | 500 | Handler returned `null` or `undefined`. |
|
|
159
|
-
| `api.invalidResponseStatus` | `buildApiResponseEnvelope` / `isRuntimeApiResult` | 500 | Handler returned an object whose `status` is not `'success'` / `'error'`. |
|
|
160
|
-
|
|
161
|
-
Consumer-emitted error codes (returned from `main(...)`) flow through `normalizeErrorResponse` unchanged. Consumers should register translations for the framework codes above plus their own.
|
|
162
|
-
|
|
163
|
-
## Stop signals
|
|
164
|
-
|
|
165
|
-
`preApiExecute` and `preApiRespond` hook handlers may return a `HookStopSignal`:
|
|
166
|
-
|
|
167
|
-
```ts
|
|
168
|
-
return { stop: true, errorCode: 'organization.disabled', httpStatus: 403 };
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
When the dispatcher sees a stop signal:
|
|
172
|
-
|
|
173
|
-
- `preApiExecute`: the handler short-circuits before `main(...)` runs. The envelope is built as `normalizeErrorResponse({ response: { errorCode }, fallbackHttpStatus: signal.httpStatus ?? 403 })`.
|
|
174
|
-
- `preApiRespond`: the already-built envelope is discarded; a fresh localized envelope is built from the signal. `transformApiResponse` and `postApiRespond` still fire on the replacement.
|
|
175
|
-
|
|
176
|
-
Other lifecycle hooks (`preApiValidate`, `postApiValidate`, `postApiExecute`, `transformApiResponse`, `postApiRespond`) do not honor stop signals — they are pre-validation observation, post-execution observation, or pure mutation slots.
|
|
177
|
-
|
|
178
|
-
## Locale extraction
|
|
179
|
-
|
|
180
|
-
Order of precedence for the `preferredLocale` argument that flows into `normalizeErrorResponse`:
|
|
181
|
-
|
|
182
|
-
1. **Socket transport**: `extractLanguageFromHeader(socket.handshake.headers['x-language'])` → `extractLanguageFromHeader(socket.handshake.headers['accept-language'])`.
|
|
183
|
-
2. **HTTP transport**: `extractLanguageFromHeader(params.xLanguageHeader)` → `extractLanguageFromHeader(params.acceptLanguageHeader)`. The HTTP listener in `@luckystack/server` is responsible for passing these headers in.
|
|
184
|
-
3. **Fallback**: `user.language` (from the session) when both headers are absent.
|
|
185
|
-
4. **Final fallback**: `defaultLanguage` from `projectConfig` when there is no session.
|
|
186
|
-
|
|
187
|
-
`extractLanguageFromHeader` returns the first valid locale token (`en`, `nl`, `de`, `fr`) from a possibly multi-value header, or `undefined`. The localized normalizer also implements its own fallback chain.
|
|
188
|
-
|
|
189
|
-
## Logging
|
|
190
|
-
|
|
191
|
-
Every error path logs at the appropriate level:
|
|
192
|
-
|
|
193
|
-
| Level | When |
|
|
194
|
-
| --- | --- |
|
|
195
|
-
| `getLogger().debug(...)` | Successful route completion, when `logging.devLogs` is on. |
|
|
196
|
-
| `getLogger().warn(...)` | Auth failures, rate-limit exceeded, method mismatch, validation failures, empty returns, malformed envelopes. All gated by `logging.devLogs`. |
|
|
197
|
-
| `getLogger().error(...)` | Handler exceptions caught by `tryCatch`. Always logged regardless of `devLogs`. Carries `{ route, transport }` context. |
|
|
198
|
-
|
|
199
|
-
The error tracker (registered via `@luckystack/error-tracking`) receives every `tryCatch` capture automatically with the context tags (`handler`, `api`, `userId`, `transport`).
|
|
200
|
-
|
|
201
|
-
## What is NOT handled here
|
|
202
|
-
|
|
203
|
-
- **Business-logic errors inside `main(...)`.** Routes return `{ status: 'error', errorCode: '...' }` directly. The envelope normalizer renders them — no thrown exception, no Sentry capture. This is by design: business outcomes (validation failures in domain code, "user already exists") are not "errors" in the tracking sense.
|
|
204
|
-
- **Localization of `errorCode` strings.** That is the consumer's responsibility — `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts` provides the translate function backed by JSON locale files in `src/_locales/`.
|
|
205
|
-
- **Frontend error rendering.** The client's `apiRequest` returns the normalized envelope. UI code consumes `result.errorCode` + `result.message` and decides how to render (toast via `notify`, inline form error, redirect).
|
|
206
|
-
- **Hook handler errors.** Failures inside a `dispatchHook` handler are isolated by the hook bus — they log via `getLogger()` + the tracker but do not affect the main pipeline.
|
|
207
|
-
|
|
208
|
-
## Hooks dispatched
|
|
209
|
-
|
|
210
|
-
The error-handling layer fires:
|
|
211
|
-
|
|
212
|
-
| Hook | Stoppable | Payload |
|
|
213
|
-
| --- | --- | --- |
|
|
214
|
-
| `preApiRespond` | yes (becomes localized error) | `{ routeName, user, response }` — last chance to mutate or replace the envelope. |
|
|
215
|
-
| `transformApiResponse` | no (mutate `response`) | `{ routeName, user, response }` — pure mutation slot. |
|
|
216
|
-
| `postApiRespond` | no | `{ routeName, user, response }` — observation after emit. |
|
|
217
|
-
| `preErrorNormalize` | no (sync; mutate `response`) | `{ response: ErrorResponseInput }` — fires inside `normalizeErrorResponse`. |
|
|
218
|
-
| `postErrorNormalize` | no (sync; mutate `normalized`) | `{ normalized: NormalizedErrorResponse }` — fires before `normalizeErrorResponse` returns. |
|
|
219
|
-
|
|
220
|
-
See [`./api-request-lifecycle.md`](./api-request-lifecycle.md) for the full hook order and `@luckystack/core/docs/hooks.md` for hook bus semantics.
|
|
221
|
-
|
|
222
|
-
## Config keys
|
|
223
|
-
|
|
224
|
-
| Key | Effect |
|
|
225
|
-
| --- | --- |
|
|
226
|
-
| `logging.devLogs` | Gates verbose per-request `debug` / `warn` logs. Handler-exception `error` logs always emit. |
|
|
227
|
-
| `defaultLanguage` | Final fallback locale for `normalizeErrorResponse`. |
|
|
228
|
-
| (registered via DI) | The localized normalizer is registered by the project's boot code. Without one, error responses still carry `errorCode` but `message` falls back to the raw key. |
|
|
229
|
-
|
|
230
|
-
## Edge cases
|
|
231
|
-
|
|
232
|
-
- **Handler returns `{ status: 'error' }` with no `errorCode`.** `normalizeErrorResponse` treats it as having a missing key — the rendered `message` falls back to the raw value (an empty string or the key literal). Always include `errorCode`.
|
|
233
|
-
- **Handler returns `httpStatus: 0` or a non-number.** `defaultHttpStatusForResponse` only treats `typeof === 'number'` as explicit; non-numbers fall back to 200/500 per the success/error branch.
|
|
234
|
-
- **Stop signal with no `httpStatus`.** Falls back to `403` for both `preApiExecute` and `preApiRespond` stops.
|
|
235
|
-
- **`tryCatch` caught a non-Error throw.** The framework wraps non-Error throws into an Error before capture; the client always gets `api.internalServerError`. The tracker receives the wrapped Error with the original `cause`.
|
|
236
|
-
- **`preApiRespond` stop on an already-error envelope.** The original error envelope is replaced by the stop signal's error envelope. Use this pattern to remap error codes (`auth.required` → `session.expired`) — but prefer the sync `preErrorNormalize` hook for code remapping since it runs in a single place.
|
|
237
|
-
- **HTTP request with no `requesterIp` and no token.** The per-route bucket keys on `ip:anonymous:...` (per route) and `ip:unknown:...` (global). All such requests share these buckets. Front your server with a load balancer that always sets `X-Forwarded-For`.
|
|
238
|
-
|
|
239
|
-
## Related
|
|
240
|
-
|
|
241
|
-
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
242
|
-
- Auth flow: [`./auth-flow.md`](./auth-flow.md)
|
|
243
|
-
- Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
|
|
244
|
-
- Generated types: [`./generated-types.md`](./generated-types.md)
|
|
245
|
-
- Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Error Code Rules", "Framework-emitted error codes", "Response Contract")
|
|
246
|
-
- Hooks: `@luckystack/core/docs/hooks.md`
|
|
247
|
-
- Error-tracker registry: `@luckystack/core/docs/error-tracker-registry.md`
|
|
1
|
+
# Error Handling
|
|
2
|
+
|
|
3
|
+
> Last updated: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@luckystack/api` produces a strict, two-shape response envelope. Every code path through the handler — malformed input, auth failure, rate-limit rejection, handler throw, handler return — ends in either `{ status: 'success', ... }` or `{ status: 'error', errorCode, errorParams?, httpStatus, message }`. The transformation from raw failure to wire envelope flows through three layers:
|
|
8
|
+
|
|
9
|
+
1. **`tryCatch`** wraps the handler's `main(...)` call. Errors are captured (auto-forwarded to Sentry / any registered tracker with context), and the tuple `[error, result]` is returned to the pipeline. Raw exceptions never reach the client.
|
|
10
|
+
2. **`buildApiResponseEnvelope`** (socket) / **`buildNetworkError`** (HTTP) maps the raw handler result to a wire envelope, inferring `httpStatus` via `defaultHttpStatusForResponse` and routing the error fields through `normalizeErrorResponse`.
|
|
11
|
+
3. **`normalizeErrorResponse`** renders the localized `message` field from the `errorCode` + `errorParams`, using the project-registered translate function. Locale is extracted in priority order: `x-language` header → `accept-language` header → `user.language`.
|
|
12
|
+
|
|
13
|
+
This means: a handler can return `{ status: 'error', errorCode: 'organization.notFound', errorParams: [{ key: 'id', value: orgId }] }` and the framework renders `Organization "abc123" not found` (or its translation) automatically. Business-logic errors and framework-emitted errors share the same envelope shape.
|
|
14
|
+
|
|
15
|
+
Logging is uniform: every handler exception is logged via `getLogger().error(...)` with the route name and transport in the context tag, plus a stack via `tryCatch`'s capture. Dev-only warn logs are gated by `logging.devLogs`.
|
|
16
|
+
|
|
17
|
+
## API Reference
|
|
18
|
+
|
|
19
|
+
### `executeApiHandler` (internal, socket transport)
|
|
20
|
+
|
|
21
|
+
**Signature**:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const executeApiHandler = async (params: {
|
|
25
|
+
apiEntry: RuntimeApiEntry;
|
|
26
|
+
normalizedData: Record<string, unknown>;
|
|
27
|
+
user: SessionLayout | null;
|
|
28
|
+
functionsObject: Record<string, unknown>;
|
|
29
|
+
resolvedName: string;
|
|
30
|
+
name: string;
|
|
31
|
+
emitStream: (payload?: ApiStreamPayload) => void;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
error: Error | null;
|
|
34
|
+
result: RuntimeApiResponse | undefined;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
}>;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Behavior**:
|
|
40
|
+
|
|
41
|
+
- Starts a Sentry span via `startSpan(name, 'api.request')`. The HTTP variant uses `'api.request.http'`.
|
|
42
|
+
- Calls `apiEntry.main({ data, user, functions, stream })` inside `tryCatch(..., undefined, { handler, api, userId, transport })`.
|
|
43
|
+
- The third argument to `tryCatch` is the auto-capture context — recorded on the registered error tracker alongside the exception.
|
|
44
|
+
- `span.end()` is called unconditionally after `main` resolves or rejects.
|
|
45
|
+
- Returns `{ error, result, durationMs }`. Caller decides what to do with each.
|
|
46
|
+
|
|
47
|
+
The HTTP transport has no extracted helper — the same flow is inlined.
|
|
48
|
+
|
|
49
|
+
### `tryCatch` (re-exported from `@luckystack/core`)
|
|
50
|
+
|
|
51
|
+
**Signature** (paraphrased):
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
async function tryCatch<T>(
|
|
55
|
+
fn: () => Promise<T>,
|
|
56
|
+
fallback?: T,
|
|
57
|
+
captureContext?: Record<string, unknown>,
|
|
58
|
+
): Promise<[Error | null, T | null]>;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Behavior**: invokes `fn`; on throw, captures the error via `captureExceptionAcrossTrackers(error, captureContext)`, and returns `[error, null]`. On success, returns `[null, result]`. Used as the universal error boundary across the framework — never use raw `try / catch`.
|
|
62
|
+
|
|
63
|
+
### `buildApiResponseEnvelope` (internal, socket transport)
|
|
64
|
+
|
|
65
|
+
**Signature**:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const buildApiResponseEnvelope = (params: {
|
|
69
|
+
resolvedName: string;
|
|
70
|
+
error: Error | null;
|
|
71
|
+
result: RuntimeApiResponse | undefined;
|
|
72
|
+
preferredLocale: string | undefined;
|
|
73
|
+
user: SessionLayout | null;
|
|
74
|
+
}): { status: 'success' | 'error'; httpStatus?: number; [key: string]: unknown };
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Behavior**:
|
|
78
|
+
|
|
79
|
+
| Condition | Returns |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `error` is non-null | Localized error envelope with `errorCode: 'api.internalServerError'`, `httpStatus: 500`. |
|
|
82
|
+
| `result` is `null` or `undefined` | Localized error envelope with `errorCode: 'api.emptyResponse'`, `httpStatus: 500`. |
|
|
83
|
+
| `result.status` is neither `'success'` nor `'error'` | Localized error envelope with `errorCode: 'api.invalidResponseStatus'`, `httpStatus: 500`. |
|
|
84
|
+
| `result.status === 'error'` | Pass through `normalizeErrorResponse({ response: result, ..., fallbackHttpStatus: defaultHttpStatusForResponse(...) })`. |
|
|
85
|
+
| `result.status === 'success'` | Spread `result`, set `httpStatus` via `defaultHttpStatusForResponse({ status: 'success', explicitHttpStatus: result.httpStatus })`. |
|
|
86
|
+
|
|
87
|
+
All four error branches and the success branch produce a fully-formed envelope ready to emit.
|
|
88
|
+
|
|
89
|
+
### `buildNetworkError` (internal closure in `handleHttpApiRequest`)
|
|
90
|
+
|
|
91
|
+
The HTTP-side equivalent. Same shape as the socket's localized-error path, but the return is typed `ApiNetworkResponse` (which has a non-optional `httpStatus: number`). Used at every HTTP early-return.
|
|
92
|
+
|
|
93
|
+
### `normalizeErrorResponse` (re-exported from `@luckystack/core`)
|
|
94
|
+
|
|
95
|
+
**Signature**:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
function normalizeErrorResponse(params: {
|
|
99
|
+
response: { status: 'error'; errorCode?: string; errorParams?: [...]; httpStatus?: number; message?: string };
|
|
100
|
+
preferredLocale?: string | null;
|
|
101
|
+
userLanguage?: string | null;
|
|
102
|
+
fallbackHttpStatus?: number;
|
|
103
|
+
}): {
|
|
104
|
+
status: 'error';
|
|
105
|
+
httpStatus: number;
|
|
106
|
+
errorCode: string;
|
|
107
|
+
errorParams?: [...];
|
|
108
|
+
message: string;
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Behavior**:
|
|
113
|
+
|
|
114
|
+
- Dispatches the `preErrorNormalize` sync hook so consumers can remap `errorCode` (e.g. rewrite `auth.required` → `session.expired`).
|
|
115
|
+
- Resolves the locale: `preferredLocale ?? userLanguage ?? defaultLanguage`.
|
|
116
|
+
- Calls the registered localized normalizer (set by `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts`) with the `errorCode` + `errorParams` to render `message`.
|
|
117
|
+
- Falls back to the raw `errorCode` string when no normalizer is registered or the key is missing from the locale.
|
|
118
|
+
- Sets `httpStatus` to `response.httpStatus ?? fallbackHttpStatus ?? 500`.
|
|
119
|
+
- Dispatches the `postErrorNormalize` sync hook so consumers can rewrite the final shape (e.g. strip stack details, add a correlation ID).
|
|
120
|
+
- Returns the localized envelope.
|
|
121
|
+
|
|
122
|
+
### `defaultHttpStatusForResponse` (re-exported from `@luckystack/core`)
|
|
123
|
+
|
|
124
|
+
**Signature**:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
function defaultHttpStatusForResponse(params: {
|
|
128
|
+
status: 'success' | 'error';
|
|
129
|
+
explicitHttpStatus?: number;
|
|
130
|
+
}): number;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Behavior**: returns `explicitHttpStatus` when present; otherwise `200` for `'success'` and `500` for `'error'`. The handler's per-response `httpStatus` (set inside `main`) always wins.
|
|
134
|
+
|
|
135
|
+
### `setSentryUser` + `startSpan` (from `@luckystack/error-tracking`)
|
|
136
|
+
|
|
137
|
+
Called early in each handler:
|
|
138
|
+
|
|
139
|
+
- `setSentryUser({ id, email })` attributes every subsequent capture (within this request's call stack) to the user. Cleared with `setSentryUser(null)` between requests by virtue of being called per-request.
|
|
140
|
+
- `startSpan(name, op)` opens a tracing span. The returned object exposes `.end()` (sometimes — depends on adapter). Both transports defensively call `span?.end?.()` to support adapters that don't implement spans.
|
|
141
|
+
|
|
142
|
+
## Framework-emitted error codes
|
|
143
|
+
|
|
144
|
+
| Code | Origin | HTTP status | Notes |
|
|
145
|
+
| --- | --- | --- | --- |
|
|
146
|
+
| `api.invalidRequest` | `validateApiMessage` (socket) | 400 | Malformed socket envelope. |
|
|
147
|
+
| `api.invalidName` | HTTP entry validation | 400 | `name` missing or not a string. |
|
|
148
|
+
| `api.invalidDataObject` | HTTP entry validation | 400 | `data` missing, null, or not an object. |
|
|
149
|
+
| `routing.invalidServiceRouteName` | `parseTransportRouteName` | 400 | Route name does not match `api/<page>/<name>/v<N>`. `errorParams[0].value` echoes the original name. |
|
|
150
|
+
| `api.notFound` | runtime map lookup | 404 | Route not registered. `errorParams[0].value` echoes the name. |
|
|
151
|
+
| `auth.required` | `checkApiAuth` | 401 | `auth.login: true` and no session. |
|
|
152
|
+
| `auth.forbidden` | `validateRequest` predicate | 403 | Predicate rejected. May carry custom `errorCode` from a future custom validator. |
|
|
153
|
+
| `auth.invalidCondition` | `validateRequest` | 500 | `auth.additional[].key` is not on the session shape. Setup error. |
|
|
154
|
+
| `api.rateLimitExceeded` | `applyApiRateLimits` | 429 | `errorParams[0]` is `{ key: 'seconds', value: resetIn }`. |
|
|
155
|
+
| `api.methodNotAllowed` | HTTP method check | 405 | `errorParams[0]` is `{ key: 'method', value: expectedMethod }`. |
|
|
156
|
+
| `api.invalidInputType` | `validateInputByType` | 400 | `errorParams[0]` is `{ key: 'message', value: <validator message> }`. |
|
|
157
|
+
| `api.internalServerError` | `tryCatch` caught a throw | 500 | The original exception is on the registered tracker; not in the wire envelope. |
|
|
158
|
+
| `api.emptyResponse` | `buildApiResponseEnvelope` | 500 | Handler returned `null` or `undefined`. |
|
|
159
|
+
| `api.invalidResponseStatus` | `buildApiResponseEnvelope` / `isRuntimeApiResult` | 500 | Handler returned an object whose `status` is not `'success'` / `'error'`. |
|
|
160
|
+
|
|
161
|
+
Consumer-emitted error codes (returned from `main(...)`) flow through `normalizeErrorResponse` unchanged. Consumers should register translations for the framework codes above plus their own.
|
|
162
|
+
|
|
163
|
+
## Stop signals
|
|
164
|
+
|
|
165
|
+
`preApiExecute` and `preApiRespond` hook handlers may return a `HookStopSignal`:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
return { stop: true, errorCode: 'organization.disabled', httpStatus: 403 };
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When the dispatcher sees a stop signal:
|
|
172
|
+
|
|
173
|
+
- `preApiExecute`: the handler short-circuits before `main(...)` runs. The envelope is built as `normalizeErrorResponse({ response: { errorCode }, fallbackHttpStatus: signal.httpStatus ?? 403 })`.
|
|
174
|
+
- `preApiRespond`: the already-built envelope is discarded; a fresh localized envelope is built from the signal. `transformApiResponse` and `postApiRespond` still fire on the replacement.
|
|
175
|
+
|
|
176
|
+
Other lifecycle hooks (`preApiValidate`, `postApiValidate`, `postApiExecute`, `transformApiResponse`, `postApiRespond`) do not honor stop signals — they are pre-validation observation, post-execution observation, or pure mutation slots.
|
|
177
|
+
|
|
178
|
+
## Locale extraction
|
|
179
|
+
|
|
180
|
+
Order of precedence for the `preferredLocale` argument that flows into `normalizeErrorResponse`:
|
|
181
|
+
|
|
182
|
+
1. **Socket transport**: `extractLanguageFromHeader(socket.handshake.headers['x-language'])` → `extractLanguageFromHeader(socket.handshake.headers['accept-language'])`.
|
|
183
|
+
2. **HTTP transport**: `extractLanguageFromHeader(params.xLanguageHeader)` → `extractLanguageFromHeader(params.acceptLanguageHeader)`. The HTTP listener in `@luckystack/server` is responsible for passing these headers in.
|
|
184
|
+
3. **Fallback**: `user.language` (from the session) when both headers are absent.
|
|
185
|
+
4. **Final fallback**: `defaultLanguage` from `projectConfig` when there is no session.
|
|
186
|
+
|
|
187
|
+
`extractLanguageFromHeader` returns the first valid locale token (`en`, `nl`, `de`, `fr`) from a possibly multi-value header, or `undefined`. The localized normalizer also implements its own fallback chain.
|
|
188
|
+
|
|
189
|
+
## Logging
|
|
190
|
+
|
|
191
|
+
Every error path logs at the appropriate level:
|
|
192
|
+
|
|
193
|
+
| Level | When |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| `getLogger().debug(...)` | Successful route completion, when `logging.devLogs` is on. |
|
|
196
|
+
| `getLogger().warn(...)` | Auth failures, rate-limit exceeded, method mismatch, validation failures, empty returns, malformed envelopes. All gated by `logging.devLogs`. |
|
|
197
|
+
| `getLogger().error(...)` | Handler exceptions caught by `tryCatch`. Always logged regardless of `devLogs`. Carries `{ route, transport }` context. |
|
|
198
|
+
|
|
199
|
+
The error tracker (registered via `@luckystack/error-tracking`) receives every `tryCatch` capture automatically with the context tags (`handler`, `api`, `userId`, `transport`).
|
|
200
|
+
|
|
201
|
+
## What is NOT handled here
|
|
202
|
+
|
|
203
|
+
- **Business-logic errors inside `main(...)`.** Routes return `{ status: 'error', errorCode: '...' }` directly. The envelope normalizer renders them — no thrown exception, no Sentry capture. This is by design: business outcomes (validation failures in domain code, "user already exists") are not "errors" in the tracking sense.
|
|
204
|
+
- **Localization of `errorCode` strings.** That is the consumer's responsibility — `registerLocalizedNormalizer` from the project's `server/utils/responseNormalizer.ts` provides the translate function backed by JSON locale files in `src/_locales/`.
|
|
205
|
+
- **Frontend error rendering.** The client's `apiRequest` returns the normalized envelope. UI code consumes `result.errorCode` + `result.message` and decides how to render (toast via `notify`, inline form error, redirect).
|
|
206
|
+
- **Hook handler errors.** Failures inside a `dispatchHook` handler are isolated by the hook bus — they log via `getLogger()` + the tracker but do not affect the main pipeline.
|
|
207
|
+
|
|
208
|
+
## Hooks dispatched
|
|
209
|
+
|
|
210
|
+
The error-handling layer fires:
|
|
211
|
+
|
|
212
|
+
| Hook | Stoppable | Payload |
|
|
213
|
+
| --- | --- | --- |
|
|
214
|
+
| `preApiRespond` | yes (becomes localized error) | `{ routeName, user, response }` — last chance to mutate or replace the envelope. |
|
|
215
|
+
| `transformApiResponse` | no (mutate `response`) | `{ routeName, user, response }` — pure mutation slot. |
|
|
216
|
+
| `postApiRespond` | no | `{ routeName, user, response }` — observation after emit. |
|
|
217
|
+
| `preErrorNormalize` | no (sync; mutate `response`) | `{ response: ErrorResponseInput }` — fires inside `normalizeErrorResponse`. |
|
|
218
|
+
| `postErrorNormalize` | no (sync; mutate `normalized`) | `{ normalized: NormalizedErrorResponse }` — fires before `normalizeErrorResponse` returns. |
|
|
219
|
+
|
|
220
|
+
See [`./api-request-lifecycle.md`](./api-request-lifecycle.md) for the full hook order and `@luckystack/core/docs/hooks.md` for hook bus semantics.
|
|
221
|
+
|
|
222
|
+
## Config keys
|
|
223
|
+
|
|
224
|
+
| Key | Effect |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| `logging.devLogs` | Gates verbose per-request `debug` / `warn` logs. Handler-exception `error` logs always emit. |
|
|
227
|
+
| `defaultLanguage` | Final fallback locale for `normalizeErrorResponse`. |
|
|
228
|
+
| (registered via DI) | The localized normalizer is registered by the project's boot code. Without one, error responses still carry `errorCode` but `message` falls back to the raw key. |
|
|
229
|
+
|
|
230
|
+
## Edge cases
|
|
231
|
+
|
|
232
|
+
- **Handler returns `{ status: 'error' }` with no `errorCode`.** `normalizeErrorResponse` treats it as having a missing key — the rendered `message` falls back to the raw value (an empty string or the key literal). Always include `errorCode`.
|
|
233
|
+
- **Handler returns `httpStatus: 0` or a non-number.** `defaultHttpStatusForResponse` only treats `typeof === 'number'` as explicit; non-numbers fall back to 200/500 per the success/error branch.
|
|
234
|
+
- **Stop signal with no `httpStatus`.** Falls back to `403` for both `preApiExecute` and `preApiRespond` stops.
|
|
235
|
+
- **`tryCatch` caught a non-Error throw.** The framework wraps non-Error throws into an Error before capture; the client always gets `api.internalServerError`. The tracker receives the wrapped Error with the original `cause`.
|
|
236
|
+
- **`preApiRespond` stop on an already-error envelope.** The original error envelope is replaced by the stop signal's error envelope. Use this pattern to remap error codes (`auth.required` → `session.expired`) — but prefer the sync `preErrorNormalize` hook for code remapping since it runs in a single place.
|
|
237
|
+
- **HTTP request with no `requesterIp` and no token.** The per-route bucket keys on `ip:anonymous:...` (per route) and `ip:unknown:...` (global). All such requests share these buckets. Front your server with a load balancer that always sets `X-Forwarded-For`.
|
|
238
|
+
|
|
239
|
+
## Related
|
|
240
|
+
|
|
241
|
+
- API request lifecycle: [`./api-request-lifecycle.md`](./api-request-lifecycle.md)
|
|
242
|
+
- Auth flow: [`./auth-flow.md`](./auth-flow.md)
|
|
243
|
+
- Rate limiting: [`./rate-limiting.md`](./rate-limiting.md)
|
|
244
|
+
- Generated types: [`./generated-types.md`](./generated-types.md)
|
|
245
|
+
- Architecture: [`/docs/ARCHITECTURE_API.md`](../../../docs/ARCHITECTURE_API.md) (see "Error Code Rules", "Framework-emitted error codes", "Response Contract")
|
|
246
|
+
- Hooks: `@luckystack/core/docs/hooks.md`
|
|
247
|
+
- Error-tracker registry: `@luckystack/core/docs/error-tracker-registry.md`
|