Skip to content

Proposal: a Respondable-style protocol for function boundary errors #90

Description

@fwal

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:

export interface Respondable {
  [symbol](): Effect.Effect<HttpServerResponse, unknown>;
}

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 comparison

Concern #89 Effect core
Who maps error → wire format onSetupError callback, per function protocol method on the error, plus defaults
Which errors can escape any, untyped declared per endpoint
Suppressing logs for expected errors ErrorReporter.ignore ErrorReporter.ignore
Schema failure default 400 / invalid-argument 400

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:

export const symbol = '~effect-firebase/FirebaseRespondable';

export interface FirebaseRespondable {
  /** 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:

class PostNotFound extends Data.TaggedError('PostNotFound')<{ id: string }> {
  readonly [ErrorReporter.ignore] = true;
  [FirebaseRespondable.symbol]() {
    return new HttpsError('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

  1. Does this subsume onSetupError, live beside it, or replace it in a later major? Leaning beside-it, with the protocol as the documented default.
  2. 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?
  3. Do the with* composition helpers surface SchemaError or FunctionSetupError? Related open question from feat(admin): recover from schema failures at function boundaries #89.
  4. Worth waiting for effect/unstable/httpapi to stabilise before copying its conventions?

Related

  • feat(admin): recover from schema failures at function boundaries #89 — the onSetupError work this responds to
  • 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.

🤖 Generated with Claude Code

https://claude.ai/code/session_013TfNC9GJPZ67knrS9W1C1v

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions