feat(admin): recover from schema failures at function boundaries - #89
Merged
Merged
Conversation
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
|
…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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
onCallEffectrethrew a decode failure, so a client that sent data not matchinginputSchemagot a genericinternalerror with no indication the request was malformed.onRequestEffectfell through to a blind500for an unparseable body.decodeDocumentDatausedEffect.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.
FunctionSetupErroris a tagged error carrying thephasethat failed (decode-input,encode-output,decode-body,encode-response,decode-document,decode-message,decode-task) and the underlying failure ascause— aSchemaError, or a plainErrorwhen the payload could not be read at all. The wrappers gain anonSetupErroroption that receives it alongside that wrapper's native arguments:An HTTP handler's hook can write its own response; a trigger's can inspect the event and skip the document:
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 aFunctionSetupErrorit raises itself. Handler errors are handled withEffect.catchTagas before. The hook is deliberately narrow, which is what lets it stay fully typed rather than takingunknown.onCallEffectruns viarunPromiseExitand distinguishes an expected rejection from a genuine defect. Rather than hand-rolling that test, it honours Effect's own convention: an error annotatedErrorReporter.ignore(asHttpApiError.BadRequestand friends are) is not reported, andHttpsErroris treated the same way since firebase-functions cannot annotate itself. AnHttpsErroralready reached the client correctly before this change (runPromiserejects with the error itself) but was also logged asDefect in onCall; that no longer happens.Coverage
onCallEffect,onRequestEffect, the four Firestore document triggers,onMessagePublishedEffectandonTaskDispatchedEffectall takeonSetupError. Two wrappers do not:onScheduleEffecttakes no schema, so it has no setup phase.onCallStreamEffect(added onmainin feat(admin): streaming callables (onCallStreamEffect) + test helpers #87, merged in here) hasinputSchemaandchunkSchemabut noonSetupError. 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:
onCallEffect, invalid inputHttpsError('internal')HttpsError('invalid-argument', <schema message>)onRequestEffect, unparseable body500400 { error: 'Invalid request body' }Encode failures reject with
HttpsError('internal')/500as 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 ofMIGRATION.md.A Pub/Sub payload that is not valid JSON now reaches
onSetupErrortoo.message.jsonparses on access and throws before any schema is applied, so that case previously became a defect and bypassed recovery entirely.Public surface
FunctionSetupErrorandisFunctionSetupError, both documented inpackages/admin/README.mdand the errors section ofpackages/effect-firebase/AGENTS.md. The recovery helpers the wrappers use internally are deliberately not exported.Tests
Specs for
onCallEffect,onRequestEffect,onDocumentCreatedEffect,onMessagePublishedEffectand the reporting predicate cover the defaults, fallback recovery, customHttpsErrorrejection, theencode-outputphase, non-JSON Pub/Sub payloads, that the handler does not run when incoming data is invalid, that a handler-raisedFunctionSetupErroris not routed throughonSetupError, 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
mainand are untouched here:nx run-many -t build typecheckhits atsbuildinforace producingTS6307s, andeffect-firebasehas 4 genuine type errors in spec files (query.spec.tsorderBy('createdAt')inferringnever, an unused_typeCheckinreference.spec.ts, twoMock<...>assignability failures intransaction.spec.ts) that look like fallout from therc.115bump. 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 inAGENTS.mddirects:packages/admin/README.md(usage, defaults, and theErrorReporter.ignoreconvention),packages/effect-firebase/AGENTS.md(wrapper list and error reference) andpackages/effect-firebase/MIGRATION.md(the two changed codes).Not included
onCallStreamEffectrecovery — see Coverage above.onCallEffectaccepting onlyEffect<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.withSchemas/withJsonEndpointand friends are untouched and still fail with plainSchemaError, so the two APIs surface different error types for the same failure. The triggers have no composition helpers at all.Respondable-style protocol for boundary errors, comparing this design againsteffect/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