Skip to content

feat(admin): recover from schema failures at function boundaries - #89

Merged
fwal merged 6 commits into
mainfrom
claude/function-setup-error-recovery-r3zj9p
Sep 18, 2026
Merged

fwal merged 6 commits into
mainfrom
claude/function-setup-error-recovery-r3zj9p

Conversation

@fwal

@fwal fwal commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Problem

Every function wrapper decodes incoming data and encodes outgoing data with the schemas you declare, but a failure there was unrecoverable and largely invisible:

  • onCallEffect rethrew a decode failure, so a client that sent data not matching inputSchema got a generic internal error with no indication the request was malformed.
  • onRequestEffect fell through to a blind 500 for an unparseable body.
  • decodeDocumentData used Effect.orDie, so a single malformed Firestore document became a defect with no way to intervene.

These failures happen outside the handler, so there was nowhere for user code to catch them.

Approach

Schema failures at a boundary are a distinct error class from handler failures, and the two want opposite retry semantics: bad data will fail identically on every retry (skip or reject it), while a handler failure may be transient (let the platform retry). This PR names that boundary class and makes it recoverable, leaving handler errors entirely alone.

FunctionSetupError is a tagged error carrying the phase that failed (decode-input, encode-output, decode-body, encode-response, decode-document, decode-message, decode-task) and the underlying failure as cause — a SchemaError, or a plain Error when the payload could not be read at all. The wrappers gain an onSetupError option that receives it alongside that wrapper's native arguments:

export const createPost = onCallEffect(
  {
    runtime,
    inputSchema: Input,
    outputSchema: Output,
    onSetupError: (error, request) =>
      Effect.fail(new HttpsError('invalid-argument', error.cause.message)),
  },
  (input, context) => handle(input, context),
);

An HTTP handler's hook can write its own response; a trigger's can inspect the event and skip the document:

export const onPostCreated = onDocumentCreatedEffect(
  {
    runtime,
    document: 'posts/{postId}',
    schema: PostModel,
    onSetupError: (error, event) =>
      Effect.logWarning(`Skipping malformed post ${event.params.postId}`),
  },
  (post) => handle(post),
);

Recovery wraps the boundary steps only, never the handler: each wrapper recovers its decode and encode steps individually via Effect.matchEffect, so an error the handler raises stays the handler's own — including a FunctionSetupError it raises itself. Handler errors are handled with Effect.catchTag as before. The hook is deliberately narrow, which is what lets it stay fully typed rather than taking unknown.

onCallEffect runs via runPromiseExit and distinguishes an expected rejection from a genuine defect. Rather than hand-rolling that test, it honours Effect's own convention: an error annotated ErrorReporter.ignore (as HttpApiError.BadRequest and friends are) is not reported, and HttpsError is treated the same way since firebase-functions cannot annotate itself. An HttpsError already reached the client correctly before this change (runPromise rejects with the error itself) but was also logged as Defect in onCall; that no longer happens.

Coverage

onCallEffect, onRequestEffect, the four Firestore document triggers, onMessagePublishedEffect and onTaskDispatchedEffect all take onSetupError. Two wrappers do not:

  • onScheduleEffect takes no schema, so it has no setup phase.
  • onCallStreamEffect (added on main in feat(admin): streaming callables (onCallStreamEffect) + test helpers #87, merged in here) has inputSchema and chunkSchema but no onSetupError. Extending it is not mechanical — what recovery means for a stream that may already have emitted chunks is a real design question — so it is deliberately left for a follow-up rather than widening this PR.

Behaviour changes

Two defaults change in ways a client can observe:

Wrapper Before After
onCallEffect, invalid input HttpsError('internal') HttpsError('invalid-argument', <schema message>)
onRequestEffect, unparseable body 500 400 { error: 'Invalid request body' }

Encode failures reject with HttpsError('internal') / 500 as before. Firestore, Pub/Sub and Tasks triggers keep logging a defect by default — only now it is recoverable. Handler signatures are unchanged throughout; this is noted under §11 of MIGRATION.md.

A Pub/Sub payload that is not valid JSON now reaches onSetupError too. message.json parses on access and throws before any schema is applied, so that case previously became a defect and bypassed recovery entirely.

Public surface

FunctionSetupError and isFunctionSetupError, both documented in packages/admin/README.md and the errors section of packages/effect-firebase/AGENTS.md. The recovery helpers the wrappers use internally are deliberately not exported.

Tests

Specs for onCallEffect, onRequestEffect, onDocumentCreatedEffect, onMessagePublishedEffect and the reporting predicate cover the defaults, fallback recovery, custom HttpsError rejection, the encode-output phase, non-JSON Pub/Sub payloads, that the handler does not run when incoming data is invalid, that a handler-raised FunctionSetupError is not routed through onSetupError, and that annotated errors are not logged as defects.

Build, lint and tests pass across all 8 projects. Typecheck passes per project.

Two pre-existing issues were confirmed identical on a clean checkout of main and are untouched here: nx run-many -t build typecheck hits a tsbuildinfo race producing TS6307s, and effect-firebase has 4 genuine type errors in spec files (query.spec.ts orderBy('createdAt') inferring never, an unused _typeCheck in reference.spec.ts, two Mock<...> assignability failures in transaction.spec.ts) that look like fallout from the rc.115 bump. They may be worth a separate look, since the race can mask them in CI.

Docs

The skill's references/api_reference.md, where this was originally documented, was removed upstream in favour of docs shipped inside the packages. Documentation now lives where the policy table in AGENTS.md directs: packages/admin/README.md (usage, defaults, and the ErrorReporter.ignore convention), packages/effect-firebase/AGENTS.md (wrapper list and error reference) and packages/effect-firebase/MIGRATION.md (the two changed codes).

Not included

  • onCallStreamEffect recovery — see Coverage above.
  • Constraining handler error channels at the boundary — e.g. onCallEffect accepting only Effect<Output, HttpsError, R> so an unhandled handler error is a compile error rather than a logged defect. Stronger guarantee, but it breaks every handler signature and deserves its own decision.
  • The composition-style helpers. withSchemas / withJsonEndpoint and friends are untouched and still fail with plain SchemaError, so the two APIs surface different error types for the same failure. The triggers have no composition helpers at all.
  • A Respondable-style protocol for boundary errors, comparing this design against effect/unstable/httpapi — written up in Proposal: a Respondable-style protocol for function boundary errors #90.

🤖 Generated with Claude Code

https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v

claude and others added 2 commits September 14, 2026 16:10
Schema decode/encode failures in function wrappers (e.g. invalid input
data against inputSchema) previously surfaced as opaque defects: onCall
rethrew them as internal errors, onRequest returned a blind 500, and the
Firestore/Pub/Sub/Tasks triggers died with no way to intervene.

- Introduce a tagged FunctionSetupError carrying the failing phase
  (decode-input, encode-output, decode-body, encode-response,
  decode-document, decode-message, decode-task) and the SchemaError cause
- Add an onSetupError option to every function wrapper to recover with a
  fallback result, a custom response, or by skipping malformed events
- onCallEffect now rejects invalid input with an invalid-argument
  HttpsError by default (internal for output encode failures) and
  propagates HttpsError failures from handlers with code intact
- onRequestEffect now responds 400 for invalid bodies and 500 for
  response encode failures by default
- decodeDocumentData fails with FunctionSetupError instead of dying so
  document triggers can recover; default behaviour (logged defect) is
  unchanged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v
The skill's api_reference.md was removed upstream in favour of docs that
ship inside the packages, so move the setup-error documentation to the
locations AGENTS.md now designates: the admin README (usage and defaults
table), the packaged AGENTS.md (wrapper list and error reference), and
MIGRATION.md for the two changed response codes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v
@github-actions github-actions Bot added 📖 docs Improvements or additions to documentation 📦 admin 📦 core labels Sep 18, 2026
@fwal fwal self-assigned this Sep 18, 2026
@fwal fwal added this to the 1.0 milestone Sep 18, 2026
@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previous findings are resolved and no new actionable failure was identified.

Summary

This PR introduces recoverable, phase-tagged schema errors at Firebase function boundaries.

  • Adds the public FunctionSetupError type and isFunctionSetupError guard.
  • Adds boundary-only onSetupError recovery to callable, HTTP, Firestore, Pub/Sub, and task wrappers.
  • Preserves handler failures without reclassifying them as setup errors.
  • Handles synchronously thrown Pub/Sub JSON parsing failures.
  • Distinguishes expected callable rejections from defects for logging.
  • Documents the public API and observable migration behavior.
  • Adds coverage for defaults, custom recovery, encoding failures, malformed messages, handler isolation, and defect reporting.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Firebase invocation] --> B[Decode incoming boundary data]
  B -->|Setup failure| C[FunctionSetupError]
  C --> D{onSetupError configured?}
  D -->|Yes| E[Run custom recovery]
  D -->|No| F[Apply wrapper default]
  B -->|Success| G[Run user handler]
  G -->|Handler failure| H[Preserve original handler error]
  G -->|Success| I{Output schema?}
  I -->|No| J[Complete invocation]
  I -->|Yes| K[Encode outgoing data]
  K -->|Setup failure| C
  K -->|Success| J
Loading

Reviews (5) · Last reviewed commit: "test(admin): import the new specs from @..."

Comment thread packages/admin/src/lib/functions/on-call.ts Outdated
Comment thread packages/admin/src/lib/functions/on-call.ts Outdated
Comment thread packages/admin/src/lib/functions/on-message-published.ts Outdated
…r.ignore

Addresses the review findings on #89 and adopts Effect's own convention for
expected errors.

Recovery was applied with a single catch around the whole pipeline, so an
error the handler itself raised was reclassified as a boundary failure if it
happened to be a FunctionSetupError — routed through onSetupError or the
wrapper default, changing what the caller saw. Each wrapper now recovers the
decode and encode steps individually via Effect.matchEffect, leaving the
handler's own error channel untouched.

onSetupError's fallback value was typed `unknown` on the base options, so a
callable declared to return T could be configured with an unrelated fallback.
The options now carry the success type, and each overload binds it: the
encoded output type when an outputSchema is set, otherwise the handler's own
return type.

Reading `message.json` parses the Pub/Sub payload on access and throws for a
body that is not valid JSON, before any schema is applied — that became a
defect and bypassed onSetupError entirely. It is now captured as a
decode-message setup error, so the documented recovery path covers it.
FunctionSetupError's cause is widened to `SchemaError | Error` to carry it.

Expected rejections are no longer reported as defects by way of a hand-rolled
HttpsError check. Effect marks expected errors with the ErrorReporter.ignore
annotation — HttpApiError.BadRequest and friends use it — so honour that for
any error, and keep HttpsError alongside it since firebase-functions cannot
annotate itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v
Comment thread packages/admin/src/lib/functions/functions.ts Outdated
fwal and others added 2 commits September 18, 2026 13:41
isExpectedRejection and recoverSetupError were re-exported through the
package barrel, making them public API that AGENTS.md then requires to be
documented in the package README and import map.

Neither is something a caller needs. The user-facing contract for
suppressing defect logs is Effect's own ErrorReporter.ignore annotation,
already documented in the admin README, and recoverSetupError is glue
shared between the trigger wrappers. Drop both from the barrel and mark
them @internal; the wrappers keep importing them directly.

The public surface added by this branch is therefore FunctionSetupError
and isFunctionSetupError, both already covered in the README and in the
errors section of the packaged AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v
main added onCallStreamEffect, callable test helpers and a response-aware
CallableContext. The only conflict was in on-call.ts, where main changed
the handler call to extractContext(request, response) while this branch
restructured the surrounding pipeline so recovery wraps the boundary steps
rather than the handler. Kept the restructured pipeline and adopted the
response-aware context inside it.

onCallStreamEffect arrives with inputSchema and chunkSchema but no
onSetupError, so it is the one wrapper this branch does not cover; what
recovery should mean for a stream that has already emitted chunks is a
design question rather than a mechanical extension. Noted in the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v
Comment thread packages/admin/src/lib/functions/on-call.spec.ts Outdated
AGENTS.md requires tests to use `@effect/vitest`, and the streaming spec
main added in #87 already does. The specs added on this branch imported
from `vitest` directly.

`@effect/vitest` re-exports all of `vitest`, so this is an import swap
with no change to the test bodies; describe, expect, it, vi and the
lifecycle hooks all resolve as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v
@fwal
fwal merged commit 9fba29d into main Sep 18, 2026
6 checks passed
@fwal
fwal deleted the claude/function-setup-error-recovery-r3zj9p branch September 18, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📦 admin 📦 core 📖 docs Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants