Skip to content

fix(core): surface a custom uploadEndpoint's presign error body - #364

Open
AminDhouib wants to merge 1 commit into
devfrom
fix/custom-endpoint-error-body
Open

fix(core): surface a custom uploadEndpoint's presign error body#364
AminDhouib wants to merge 1 commit into
devfrom
fix/custom-endpoint-error-body

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

The defect

TokenEndpointCredentials.getPresignedUrl — the strategy behind a custom
uploadEndpoint — never reads a non-ok response:

if (!response.ok) {
    throw new UpupNetworkError(
        `Presign request failed: ${response.status} ${response.statusText}`,
        response.status,
    )   // <- body never read
}

So whatever the host application wrote into the body is discarded before any
handler can see it. Since onError is typed (errorMessage: string) => void,
the thrown error's .status is not reachable either — the HTTP status survives
only as text inside upup's own message.

That leaves a consumer whose presign endpoint enforces real rules with exactly
one way to recover its own copy: regex the status back out of our message.

Downstream evidence

Postify (usepostify.com) runs upup v3 in uploadEndpoint mode. Its
/api/upload-token route returns HTTP 413 with a real sentence — "File exceeds
your plan's 4608MB limit. Upgrade for larger uploads."
— and the user was shown
"Presign request failed: 413 Payload Too Large" instead. The workaround it
shipped, lib/compose/upload-error-copy.ts, is a status-string matcher:

const PRESIGN_FAILURE = /^Presign request failed: (\d{3})\b/

export function uploadErrorCopy(message: string, { maxFileSizeMB }) {
    const status = presignFailureStatus(message)
    if (status === 413) return `File exceeds your plan's ${maxFileSizeMB}MB limit. …`
    if (status === 401 || status === 403) return 'Your sign-in expired …'
    // …
}

Every consumer with a rule-enforcing presign endpoint has to write some version
of that file, and it re-breaks the moment our message wording changes. This
patch is what deletes it.

The fix

The strategy now reads the body and builds its error through
uploadErrorFromResponse — the same helper direct-PUT, multipart, server
credentials and drive transfer already use. The body's message becomes
error.message (which is what onError receives), a code field lands on
error.code, and error.status still carries the HTTP status.

One supporting fix in parseErrorBody: it selected its message with
error ?? msg, so a body shaped { message: "...", error: true } — common in
hand-rolled endpoints — took the boolean, failed the typeof === 'string'
guard, and fell all the way through to the raw-JSON text fallback. It now
prefers a string error and otherwise keeps message.

Compatibility

Deliberately no public-API change — onError keeps its
(errorMessage: string) => void signature, and nothing is added to any package's
export surface.

  • Same thrown class. kind: 'network' builds UpupNetworkError, exactly
    what this path threw before, still carrying .status.
  • Byte-identical message when there is no body. If the body is empty,
    whitespace, or unreadable, the message stays
    Presign request failed: <status> <statusText> — the wording this path has
    always thrown. A consumer matching the old string sees no change on that path.
    (This is why the fallback is explicit rather than letting
    uploadErrorFromResponse fall back to its own bare "<status> <statusText>".)
  • The only observable change is that an endpoint which did send a body now
    has that body surface instead of being dropped. That is the bug.
  • parseErrorBody's change only affects bodies where error is present and not
    a string — which previously produced a raw JSON dump, never something a
    consumer could have been relying on.
  • Consumers already have state.uploadErrorCode (rendered by FileList in
    react and angular today), so a code from the body is usable for branching
    without touching prose.

Tests

packages/core/tests/strategies/token-endpoint.test.ts — a new
endpoint error body block covering: the endpoint's message + code replacing
the status line, the { message, error: true } shape, a plain-text body, the
class/status still being UpupNetworkError/413, and both legacy-wording
fallbacks (empty body, text() rejecting).

The three pre-existing error-path tests mock a response with no text() at
all; they are left exactly as they were, so they now double as the
unreadable-body backward-compatibility pin.

packages/core/src/__tests__/errors.test.ts gains the parseErrorBody case.

RED-before / GREEN-after evidence is in a comment below.

Docs + changeset

apps/landing/content/docs/api-reference/error-codes.mdx gains a paragraph
under Errors from your own endpoints — that section previously listed the four
strategies routing through uploadErrorFromResponse and the custom presign call
was not one of them.

A patch changeset is included per repo convention.

Not a release

No release is cut by this PR and none should be inferred from it. No version
bump, no tag, no npm publish — the changeset only queues an entry for whenever
you decide to run the release cycle. Merging this to dev does not publish
anything.

Targeted at dev rather than master per CLAUDE.md's "Do not merge, PR, or
push to master without an explicit maintainer decision."

`TokenEndpointCredentials.getPresignedUrl` threw
`Presign request failed: <status> <statusText>` without ever reading a non-ok
response, so the sentence a self-hosted token endpoint wrote for the user — a
plan-limit message, an expired-session notice — was discarded before any
handler saw it. With `onError` typed `(errorMessage: string) => void` the
thrown error's `.status` is not reachable either, which left consumers matching
the HTTP status back out of upup's own message text as the only way to recover
their own copy.

The strategy now reads the body and builds its error through
`uploadErrorFromResponse`, the helper direct-PUT, multipart, server credentials
and drive transfer already use: the body's message becomes `error.message`,
a `code` field lands on `error.code`, and `error.status` still carries the
status.

`parseErrorBody` selected its message with `error ?? msg`, so a body shaped
`{ message, error: true }` took the boolean, failed the string guard, and fell
through to the raw-JSON text fallback. It now prefers a *string* `error` and
otherwise keeps `message`.

Backward compatible by construction: same thrown class (`UpupNetworkError`
via `kind: 'network'`), and when the body is empty, whitespace or unreadable
the message stays byte-identical to the old wording. No public API change —
`onError` keeps its signature and no export surface moves.

RED before (vitest, packages/core):

    FAIL tests/strategies/token-endpoint.test.ts > endpoint error body >
         throws the endpoint's own message and code instead of the status line
      Expected: "File exceeds your plan's 4608MB limit. Upgrade for larger uploads."
      Received: "Presign request failed: 413 Payload Too Large"
    FAIL ... > lifts a `message` field that sits beside a non-string `error` flag
    FAIL ... > uses a plain-text error body verbatim
    FAIL src/__tests__/errors.test.ts > parseErrorBody >
         keeps `message` when a non-string `error` flag sits beside it
    Test Files  2 failed (2)
         Tests  4 failed | 59 passed (63)

GREEN after: 63 passed (63); full core suite 1699 passed (142 files);
react 644 passed, server 337 passed against a rebuilt core dist.
The three pre-existing error-path tests mock a response with no `text()` at
all and are left untouched, so they now double as the unreadable-body
compatibility pin.
@codesandbox

codesandbox Bot commented Aug 27, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@AminDhouib

Copy link
Copy Markdown
Member Author

Test evidence

Run on Node 20.20.2 (.nvmrc), pnpm 10.11.0, packages/core, vitest 4.1.2.

RED — new tests against unmodified src (git stash push -- packages/core/src/errors.ts packages/core/src/strategies/token-endpoint.ts):

 FAIL  tests/strategies/token-endpoint.test.ts > TokenEndpointCredentials — endpoint error body >
       throws the endpoint's own message and code instead of the status line
   Expected: "File exceeds your plan's 4608MB limit. Upgrade for larger uploads."
   Received: "Presign request failed: 413 Payload Too Large"

 FAIL  ... > lifts a `message` field that sits beside a non-string `error` flag
   Expected: "File exceeds your plan's 4608MB limit."
   Received: "Presign request failed: 413 Payload Too Large"

 FAIL  ... > uses a plain-text error body verbatim
   Expected: "Your sign-in expired before the upload started."
   Received: "Presign request failed: 403 Forbidden"

 FAIL  src/__tests__/errors.test.ts > parseErrorBody >
       keeps `message` when a non-string `error` flag sits beside it

 Test Files  2 failed (2)
      Tests  4 failed | 59 passed (63)

The three tests that pass in the RED run are the deliberate compatibility
pins — still throws UpupNetworkError carrying the status and both
legacy-wording fallbacks behave identically before and after.

GREEN — same two files with the fix restored:

 Test Files  2 passed (2)
      Tests  63 passed (63)

Full gates (all via rtk proxy + raw exit codes, per CLAUDE.md's machine notes):

Gate Result
vitest run (packages/core) 143 files, 1722 passed — exit 0
tsc --noEmit exit 0
tsc -p tsconfig.test.json --noEmit exit 0
eslint . --max-warnings 0 (core) exit 0
prettier --check (all 9 packages' src) exit 0
test:quality 391 test files + 5 workflows clean, 0 exceptions
@upupjs/react test 71 files, 644 passed
@upupjs/server test 32 files, 337 passed, 6 skipped
pre-push (typecheck + turbo lint + knip) exit 0

pnpm run e2e was not run: it needs MinIO via Docker plus a six-storybook
webServer boot, and this box was running several concurrent installs at the
time. Nothing in this change touches a network or DOM path the e2e gate
covers, but CI's E2E job is the check to watch.

One environment note for whoever picks this up: on a fresh clone the react
suite fails until pnpm --filter @upupjs/core build has run, because react
resolves @upupjs/core to dist/. That is principle 5 working as documented,
not a defect — just a trap for a first-time contributor whose pre-commit hook
fires before any build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant