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
2 changes: 1 addition & 1 deletion .changeset/22283-storage-limits-honoured.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/service-storage': minor
---
Expand All @@ -12,7 +12,7 @@

Setup's File Storage page has always rendered a "Limits" group — "Max upload size (MB)", "Presigned URL TTL (seconds)" and "Upload session TTL (seconds)" — and saved it, but nothing read it. `StorageServicePlugin` now reads the group on every settings pass and its upload doors honour it, on the plugin's own mount and on a host's `mountStorageRoutes` mount alike.

**What stops being accepted.** Where the plugin is bound to the `storage` settings namespace (its default, `bindToSettings: true`, with a settings service present), an upload larger than the resolved `max_upload_mb` is refused with `413` and the standard code `VALIDATION_ERROR`, in the usual error envelope, with a message naming the limit and the setting. Nothing is stored: no `sys_file` or `sys_upload_session` row is written and no byte reaches the adapter. The doors that judge it:
**What stops being accepted.** Where the plugin is bound to the `storage` settings namespace (its default, `bindToSettings: true`, with a settings service present), an upload larger than the resolved `max_upload_mb` is refused with `413` and the registered code `PAYLOAD_TOO_LARGE`, in the usual error envelope, with a message naming the limit and the setting. Nothing is stored: no `sys_file` or `sys_upload_session` row is written and no byte reaches the adapter. The doors that judge it:

- `POST /storage/upload/presigned` — the declared `size`;
- `POST /storage/upload/chunked` — the declared `totalSize`;
Expand Down
11 changes: 11 additions & 0 deletions .changeset/22314-storage-payload-too-large-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): the error-code ledger lists `PAYLOAD_TOO_LARGE` under `@objectstack/service-storage`

Clause-②: yes (widening: a new owner provenance row in the published error-code ledger)

`ERROR_CODE_LEDGER['@objectstack/service-storage']` now lists `PAYLOAD_TOO_LARGE` (`413`), the code `@objectstack/rest` already registers for its import row ceilings. The storage upload doors answer it for an upload over the File Storage settings' `max_upload_mb`: the presigned upload's declared `size`, the chunked upload's declared `totalSize`, the local raw PUT body and each chunk's running total.

No code is added. `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. What widens is the per-package list a reader consults to learn which packages answer a code: a code emitted by several packages is listed under each of them.
43 changes: 31 additions & 12 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ navTitle: Error Code Catalog
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
---

ObjectStack uses a structured error system with **9 error categories** and **51 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
ObjectStack uses a structured error system with **9 error categories** and **52 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.

This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
Expand Down Expand Up @@ -62,16 +62,10 @@ entry's cross-reference sentence so the in-process one stays findable.
**Fix:** Check the `fields` array for specific field-level issues.
**Retry:** `no_retry`

<Callout type="warn">
**`VALIDATION_ERROR` is `400` with one exception: the storage upload doors answer `413` for
an upload over the File Storage settings' `max_upload_mb`.**

The presigned-upload door (the declared `size`), the chunked-upload start (the declared
`totalSize`), the local adapter's raw upload (the body) and the chunk door (the bytes the
upload already holds plus the chunk) refuse it before anything is stored. The message names
the size, the limit and the setting. Raise the setting or send a smaller file; resending the
same upload cannot succeed. The protocol page carries the same exception:
[`VALIDATION_ERROR`](/docs/protocol/kernel/error-handling#validation_error).
<Callout type="info">
**A request too large for its door is not this code.** An upload over the File Storage
settings' `max_upload_mb`, and an import over its route's row ceiling, answer `413`
[`PAYLOAD_TOO_LARGE`](#payload_too_large).
</Callout>

```json
Expand Down Expand Up @@ -510,6 +504,31 @@ an environment scope (no `X-Environment-Id` header and no hostname mapping).

---

## Size Limit Errors (413)

`PAYLOAD_TOO_LARGE` is an [error-code ledger](/docs/references/api/error-code-ledger) code, listed
under each package that answers it — `@objectstack/rest` and `@objectstack/service-storage` — not a
standard-catalog member, so its status is published here rather than inherited from a category.
The standard catalog names no `413` member.

### `PAYLOAD_TOO_LARGE`

**HTTP Status:** 413
**Cause:** The request carries more than its door accepts:

- **An upload over the File Storage settings' `max_upload_mb`.** The presigned-upload door (the
declared `size`), the chunked-upload start (the declared `totalSize`), the local adapter's raw
upload (the body) and the chunk door (the bytes the upload already holds plus the chunk) refuse
it before anything is stored. The message names the size, the limit and the setting.
- **An import over its route's row ceiling** — 5,000 rows on the synchronous route, 50,000 on the
job route ([size limits](/docs/data-modeling/import-mappings#size-limits)).

**Fix:** Raise the setting or send a smaller file; split the import across requests. Resending
the same request cannot succeed.
**Retry:** `no_retry`

---

## Rate Limit Errors (429)

### `RATE_LIMIT_EXCEEDED`
Expand Down Expand Up @@ -917,7 +936,7 @@ async function handleApiCall() {
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` |
| 413 | `validation` | `VALIDATION_ERROR` on an upload over the storage settings' `max_upload_mb` (see [above](#validation_error)) — this row is an exception to the 400 row, not a second home for the code |
| 413 | `validation` | `PAYLOAD_TOO_LARGE` — an upload over the storage settings' `max_upload_mb`, or an import over its row ceiling (see [above](#payload_too_large)) |
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
| 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` |
| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR` |
Expand Down
14 changes: 6 additions & 8 deletions content/docs/protocol/kernel/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ ObjectStack uses standard HTTP status codes:
| **403** | Forbidden | Authenticated but insufficient permissions |
| **404** | Not Found | Resource doesn't exist |
| **409** | Conflict | Resource already exists or version mismatch |
| **413** | Content Too Large | An upload over the storage settings' `max_upload_mb` — answered as [`VALIDATION_ERROR`](#validation_error), its one exception to 400 |
| **413** | Content Too Large | An upload over the storage settings' `max_upload_mb`, or an import over its row ceiling — answered as [`PAYLOAD_TOO_LARGE`](/docs/api/error-catalog#payload_too_large) |
| **422** | Unprocessable Entity | Semantic validation failed (e.g. metadata spec validation); also an absent `controlled_by_parent` master reference — see [`MISSING_REQUIRED_FIELD`](#missing_required_field) |
| **429** | Too Many Requests | Rate limit exceeded |
| **500** | Internal Server Error | Server-side error |
Expand Down Expand Up @@ -402,7 +402,7 @@ if (body.code === 'VALIDATION_FAILED') {
```

#### `VALIDATION_ERROR`
**HTTP Status:** 400 — with one documented exception, which answers **413** (see below)
**HTTP Status:** 400
**Meaning:** The request is malformed — the generic "you sent something wrong" code
**Envelope:** **both**, and one emitter writes neither (see the warning below)

Expand All @@ -418,12 +418,10 @@ Where it comes from:
specific.
- **Query-parameter refusals.** `refuseUnknownQueryParams` and `refuseRepeatedQueryParams`
answer 400 `VALIDATION_ERROR` for an unrecognised or repeated query parameter.
- **Upload size refusals — the one 413.** The storage upload doors answer **413**
`VALIDATION_ERROR` for an upload over the File Storage settings' `max_upload_mb`: the
presigned-upload door (the declared `size`), the chunked-upload start (the declared
`totalSize`), the local adapter's raw upload (the body) and the chunk door (the bytes the
upload already holds plus the chunk), each before anything is stored. The message names the
size, the limit and the setting.

Not from a request too large for its door: an upload over the File Storage settings'
`max_upload_mb` answers **413** with the registered code
[`PAYLOAD_TOO_LARGE`](/docs/api/error-catalog#payload_too_large), not this one.

**Nested** (the dispatcher door, status-derived — `GET /api/v1/packages/:id?version=1&version=2`):
```json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('[#22283] the storage Limits settings are enforced at the upload doors
expect(over.status, 'a declared size one byte over 100 MB').toBe(413);
const refusal = (await over.json()) as any;
expect(refusal.success).toBe(false);
expect(refusal.error.code).toBe('VALIDATION_ERROR');
expect(refusal.error.code).toBe('PAYLOAD_TOO_LARGE');

const ok = await presign(100 * MIB);
expect(ok.status, 'exactly 100 MB').toBe(200);
Expand All @@ -88,7 +88,7 @@ describe('[#22283] the storage Limits settings are enforced at the upload doors
over = await presign(MIB + 1);
}
expect(over.status, 'a declared size one byte over the saved 1 MB').toBe(413);
expect(((await over.json()) as any).error.code).toBe('VALIDATION_ERROR');
expect(((await over.json()) as any).error.code).toBe('PAYLOAD_TOO_LARGE');

const ok = await presign(10);
expect(ok.status).toBe(200);
Expand All @@ -99,7 +99,7 @@ describe('[#22283] the storage Limits settings are enforced at the upload doors
const path = String(data.uploadUrl).replace(/^https?:\/\/[^/]+/, '');
const tooBig = await rawPut(path, MIB + 1);
expect(tooBig.status, 'a body one byte over the saved 1 MB').toBe(413);
expect(((await tooBig.json()) as any).error.code).toBe('VALIDATION_ERROR');
expect(((await tooBig.json()) as any).error.code).toBe('PAYLOAD_TOO_LARGE');
const fits = await rawPut(path, MIB);
expect(fits.status, 'a body of exactly 1 MB').toBeLessThan(300);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ doors whenever the plugin is bound to settings (the default):

| Setting | Default | Applies to |
|:---|:---|:---|
| `max_upload_mb` | 100 (MB, counted as MiB) | Every upload door: the presigned upload's declared `size`, the chunked upload's declared `totalSize`, the local raw PUT body and each chunk's running total. Over it ⇒ `413` with code `VALIDATION_ERROR`, before anything is stored. |
| `max_upload_mb` | 100 (MB, counted as MiB) | Every upload door: the presigned upload's declared `size`, the chunked upload's declared `totalSize`, the local raw PUT body and each chunk's running total. Over it ⇒ `413` with code `PAYLOAD_TOO_LARGE`, before anything is stored. |
| `presigned_ttl` | 3600 s | Presigned upload URLs and non-gated download URLs issued from then on. |
| `session_ttl` | 86400 s | Chunked upload sessions started from then on. |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,12 +248,12 @@ describe('storage error envelope (#3675)', () => {
},
},
{
// #22283: the saved `max_upload_mb`, enforced at the upload doors — the
// standard member the platform derives for a 413 (no 413 member exists,
// and `PAYLOAD_TOO_LARGE` is registered under another package).
// #22283: the saved `max_upload_mb`, enforced at the upload doors —
// #22314: the ledger code registered for "too large" under this
// package's owner key, not the status-derived `VALIDATION_ERROR`.
name: 'an upload declared over the saved max_upload_mb',
status: 413,
code: 'VALIDATION_ERROR',
code: 'PAYLOAD_TOO_LARGE',
run: async () => {
const routes = mount(await tmpAdapter(), new StorageMetadataStore(null), {
limitsSnapshot: () => ({ maxUploadMb: { value: 1, authored: true } }),
Expand Down
6 changes: 3 additions & 3 deletions packages/services/service-storage/src/storage-limits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* - **§1 every upload door refuses over the limit, and stores nothing.** The
* presigned door (declared `size`), the chunked start (declared
* `totalSize`), the local raw PUT (the body) and the chunk door (the bytes
* received so far plus this chunk) each answer `413` / `VALIDATION_ERROR`
* received so far plus this chunk) each answer `413` / `PAYLOAD_TOO_LARGE`
* in the ADR-0112 envelope, with no row written and no byte handed to the
* adapter. Each has its control at exactly the limit, which is accepted.
* - **§2 the TTLs and the precedence.** A saved TTL is what a presigned URL
Expand Down Expand Up @@ -117,7 +117,7 @@ async function call(
function expectTooLarge(answer: Answer): void {
expect(answer.status).toBe(413);
expect(answer.body?.success).toBe(false);
expect(answer.body?.error?.code).toBe('VALIDATION_ERROR');
expect(answer.body?.error?.code).toBe('PAYLOAD_TOO_LARGE');
expect(String(answer.body?.error?.message)).toContain('max_upload_mb');
}

Expand Down Expand Up @@ -148,7 +148,7 @@ const SAVED_ONE_MB: StorageLimitsSnapshot = { maxUploadMb: { value: 1, authored:
// ---------------------------------------------------------------------------

describe('[#22283] §1 · the upload doors refuse a file over max_upload_mb, before anything is stored', () => {
it('presigned door: a declared size over the limit is 413 VALIDATION_ERROR — no row, no URL minted', async () => {
it('presigned door: a declared size over the limit is 413 PAYLOAD_TOO_LARGE — no row, no URL minted', async () => {
const { adapter, store, routes } = await mountDoors(SAVED_ONE_MB);
const createFile = vi.spyOn(store, 'createFile');
const presign = vi.spyOn(adapter, 'getPresignedUpload');
Expand Down
22 changes: 14 additions & 8 deletions packages/services/service-storage/src/storage-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { randomUUID } from 'node:crypto';
import type { IHttpServer, IHttpRequest, IHttpResponse, IStorageService } from '@objectstack/spec/contracts';
import type { StandardErrorCode } from '@objectstack/spec/api';
import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api';
// The declared envelope is written in ONE place for the whole platform (#3973).
import { sendOk, sendError } from '@objectstack/types';
// [#15999] The BRAND predicate, never `instanceof` — this error crosses package
Expand Down Expand Up @@ -76,15 +76,21 @@ const ORGANIZATION_CHANGED_MESSAGE =
* written.
*
* `413` because that is the condition (RFC 9110, content too large).
* `VALIDATION_ERROR` because it is the standard-catalog member the platform
* itself derives for a `413` (`standardErrorCodeForHttpStatus`, ADR-0112): the
* catalog names no `413` member, and the ledger's `PAYLOAD_TOO_LARGE` row is
* registered under `@objectstack/rest` alone, so stamping it here would be an
* unregistered emitter (`check:error-code-provenance`). The status carries the
* specific condition; the message names the limit and where it is set.
* `PAYLOAD_TOO_LARGE` because it is the one registered code for that condition
* (ADR-0112 ledger, listed under this package's owner key beside
* `@objectstack/rest`, which answers it for its import row ceilings): a client
* branches on "too large" by the code alone, at every door that refuses it.
* Not `VALIDATION_ERROR`, the bucket `standardErrorCodeForHttpStatus` derives
* for a `413`: the standard catalog names no `413` member, so that code would
* say only "malformed request" and leave the client to read the status.
*
* Typed `RegisteredErrorCode` so a misspelling, or the code leaving the
* ledger, fails to compile; that this package's own owner key lists it is
* `check:error-code-provenance`'s to hold. The message names the limit and
* where it is set.
*/
const UPLOAD_TOO_LARGE_STATUS = 413;
const UPLOAD_TOO_LARGE_CODE: StandardErrorCode = 'VALIDATION_ERROR';
const UPLOAD_TOO_LARGE_CODE: RegisteredErrorCode = 'PAYLOAD_TOO_LARGE';

/** The refusal's message: what was measured, the limit, and the one setting that moves it. */
function uploadTooLargeMessage(measured: string, maxUploadBytes: number): string {
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,16 @@ export const ERROR_CODE_LEDGER = {
'INTERNAL',
'INVALID_REQUEST',
'INVALID_RESUME_TOKEN',
// [#22314] 413 from every upload door over the resolved `max_upload_mb`
// (#22283): the presigned door's declared `size`, the chunked start's
// declared `totalSize`, the local raw PUT body and each chunk's running
// total, by `content-length` before the body is read and by the bytes read
// — `storage-routes.ts`, refused before a row is written or a byte stored.
// Second EMITTER of the code `@objectstack/rest` already registers for its
// import row ceilings (both import routes); one condition — the request
// carries more than the door accepts — one code a client branches on
// without reading the status. Provenance, not identity (see above).
'PAYLOAD_TOO_LARGE',
'UPLOAD_SESSION_EXPIRED', // chunk/complete against a session past its own expires_at (#7667)
'UPLOAD_SESSION_NOT_FOUND',
],
Expand Down
Loading