You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Design note following #89. Not a plan to implement as-is — the point is to compare what we built against how Effect solves the same problem in core, and decide which parts are worth adopting.
Where this comes from
#89 added onSetupError, a per-wrapper callback that turns a schema failure at a function boundary into a response. While reviewing prior art it turned out Effect solves the same problem in effect/unstable/httpapi and effect/unstable/http — now in core, since v4 folded @effect/platform in — and does it with three mechanisms, none of them a callback option.
A fixed vocabulary of boundary errors.httpapi/HttpApiError exports BadRequest, Unauthorized, Forbidden, NotFound, Conflict, UnprocessableEntity, InternalServerError and the rest as Schema.Classes. Endpoints declare which of them they can return, so the error set is part of the contract.
A protocol on the error, not a hook at the boundary.HttpServerRespondable is a symbol-keyed method an error implements to render itself:
toResponseOrElse(u, orElse) converts anything: Respondable values render themselves, and per its docs "schema errors become 400 responses, and no-such-element errors become 404 responses", otherwise the fallback. The mapping lives on the error, with defaults for well-known ones.
An annotation for logging policy.ErrorReporter defines Reportable with ignore, severity and attributes, declared globally on Error. HttpApiError.BadRequest sets readonly [ErrorReporter.ignore] = true.
That third one is already adopted — #89 now honours ErrorReporter.ignore instead of the instanceof HttpsError check it originally shipped with. This issue is about the first two.
The defaults already agree. The difference is where the mapping lives.
The sketch
A FirebaseRespondable protocol, with each wrapper family reading the part it can use:
exportconstsymbol='~effect-firebase/FirebaseRespondable';exportinterfaceFirebaseRespondable{/** For callables: how this error rejects the call. */readonly[symbol]: ()=>HttpsError;}
A domain error then declares its own boundary behaviour once, instead of every function that can raise it repeating a catchTag:
classPostNotFoundextendsData.TaggedError('PostNotFound')<{id: string}>{readonly[ErrorReporter.ignore]=true;[FirebaseRespondable.symbol](){returnnewHttpsError('not-found',`No post ${this.id}`);}}
with SchemaError → invalid-argument / 400 as the built-in default, matching both Effect and what #89 already does.
What it buys, and what it costs
For: mapping is declared once per error rather than once per function; it composes with the withSchemas helpers, which currently have no way to express boundary behaviour; and it follows a convention an Effect user already knows.
Against: it is a second mechanism next to onSetupError, and two ways to do the same thing is worse than one unless the old one goes away. It also does not replace onSetupError outright — the hook can close over the event or response, which a method on the error cannot (onSetupError: (error, event) => logWarning(\skipping ${event.params.postId}`)` has no protocol equivalent). So the honest framing is: the protocol handles this error always means this response, the hook handles this function wants to do something specific about it.
Open questions
Does this subsume onSetupError, live beside it, or replace it in a later major? Leaning beside-it, with the protocol as the documented default.
Triggers have no wire format — the meaningful boundary decision there is skip vs retry (see feat(admin): recover from schema failures at function boundaries #89's discussion of onScheduleEffect rethrowing so Cloud Scheduler retries, while the Firestore triggers swallow). Is that a second protocol method, or out of scope?
The trigger-composition question from the same discussion: the Firestore trigger handler signature is Effect<void, never, R>, so there is no error channel for a composed decode failure to live in. Relaxing it to Effect<void, E, R> is what would make per-failure retry intent expressible, and it interacts with question 2 above.
Design note following #89. Not a plan to implement as-is — the point is to compare what we built against how Effect solves the same problem in core, and decide which parts are worth adopting.
Where this comes from
#89 added
onSetupError, a per-wrapper callback that turns a schema failure at a function boundary into a response. While reviewing prior art it turned out Effect solves the same problem ineffect/unstable/httpapiandeffect/unstable/http— now in core, since v4 folded@effect/platformin — and does it with three mechanisms, none of them a callback option.A fixed vocabulary of boundary errors.
httpapi/HttpApiErrorexportsBadRequest,Unauthorized,Forbidden,NotFound,Conflict,UnprocessableEntity,InternalServerErrorand the rest asSchema.Classes. Endpoints declare which of them they can return, so the error set is part of the contract.A protocol on the error, not a hook at the boundary.
HttpServerRespondableis a symbol-keyed method an error implements to render itself:toResponseOrElse(u, orElse)converts anything:Respondablevalues render themselves, and per its docs "schema errors become400responses, and no-such-element errors become404responses", otherwise the fallback. The mapping lives on the error, with defaults for well-known ones.An annotation for logging policy.
ErrorReporterdefinesReportablewithignore,severityandattributes, declared globally onError.HttpApiError.BadRequestsetsreadonly [ErrorReporter.ignore] = true.That third one is already adopted — #89 now honours
ErrorReporter.ignoreinstead of theinstanceof HttpsErrorcheck it originally shipped with. This issue is about the first two.The comparison
onSetupErrorcallback, per functionErrorReporter.ignoreErrorReporter.ignoreinvalid-argumentThe defaults already agree. The difference is where the mapping lives.
The sketch
A
FirebaseRespondableprotocol, with each wrapper family reading the part it can use:A domain error then declares its own boundary behaviour once, instead of every function that can raise it repeating a
catchTag:with
SchemaError → invalid-argument/ 400 as the built-in default, matching both Effect and what #89 already does.What it buys, and what it costs
For: mapping is declared once per error rather than once per function; it composes with the
withSchemashelpers, which currently have no way to express boundary behaviour; and it follows a convention an Effect user already knows.Against: it is a second mechanism next to
onSetupError, and two ways to do the same thing is worse than one unless the old one goes away. It also does not replaceonSetupErroroutright — the hook can close over theeventorresponse, which a method on the error cannot (onSetupError: (error, event) => logWarning(\skipping ${event.params.postId}`)` has no protocol equivalent). So the honest framing is: the protocol handles this error always means this response, the hook handles this function wants to do something specific about it.Open questions
onSetupError, live beside it, or replace it in a later major? Leaning beside-it, with the protocol as the documented default.onScheduleEffectrethrowing so Cloud Scheduler retries, while the Firestore triggers swallow). Is that a second protocol method, or out of scope?with*composition helpers surfaceSchemaErrororFunctionSetupError? Related open question from feat(admin): recover from schema failures at function boundaries #89.effect/unstable/httpapito stabilise before copying its conventions?Related
onSetupErrorwork this responds toEffect<void, never, R>, so there is no error channel for a composed decode failure to live in. Relaxing it toEffect<void, E, R>is what would make per-failure retry intent expressible, and it interacts with question 2 above.🤖 Generated with Claude Code
https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v