Trovella Wiki

Error Handling

How tRPC errors are thrown, formatted, and reported -- including Zod validation, error codes, and Sentry integration.

The API layer handles errors at three levels: automatic Zod input validation, explicit TRPCError throws in router handlers, and Sentry reporting for unexpected failures.

Error Codes

tRPC CodeHTTP StatusWhen to Use
UNAUTHORIZED401No session or expired session. Thrown by protectedProcedure.
FORBIDDEN403CASL ability check failed, or user is not a member of the org. Thrown by authorizedProcedure and router handlers.
PRECONDITION_FAILED412No active organization selected. Thrown by tenantProcedure.
NOT_FOUND404Resource does not exist. Thrown explicitly in router handlers.
BAD_REQUEST400Input validation failed. Thrown automatically by tRPC when Zod rejects the input.

Throwing Errors in Routers

All router errors use TRPCError from @trpc/server. Include a message when the default is not descriptive enough:

// Simple -- code is self-explanatory
if (ctx.ability.cannot("read", "Organization")) {
  throw new TRPCError({ code: "FORBIDDEN" });
}

// With message -- when the code alone is ambiguous
if (target.role === "owner") {
  throw new TRPCError({
    code: "FORBIDDEN",
    message: "Cannot change an owner's role",
  });
}

// Not found after a query
const [plan] = await ctx.db
  .select()
  .from(researchPlan)
  .where(eq(researchPlan.id, input.planId))
  .limit(1);

if (!plan) throw new TRPCError({ code: "NOT_FOUND" });

Zod Validation Errors

tRPC runs the Zod .input() schema automatically before the handler executes. If validation fails, the client receives a BAD_REQUEST error with structured Zod details.

The error formatter in packages/api/src/trpc.ts augments the standard error shape:

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? z.treeifyError(error.cause) : null,
      },
    };
  },
});

On the client side, a validation error response looks like:

{
  "error": {
    "message": "...",
    "code": -32600,
    "data": {
      "code": "BAD_REQUEST",
      "httpStatus": 400,
      "zodError": {
        "name": { "errors": ["String must contain at least 1 character(s)"] },
        "limit": { "errors": ["Number must be greater than or equal to 1"] }
      }
    }
  }
}

The zodError field uses Zod v4's treeifyError format, which produces a nested object keyed by field path. This makes it straightforward to map errors to form fields on the client.

Sentry Integration

The Next.js route handler at apps/web/src/app/api/trpc/[trpc]/route.ts reports unexpected errors to Sentry while filtering out expected client errors:

const CLIENT_ERROR_CODES = new Set([
  "UNAUTHORIZED",
  "FORBIDDEN",
  "NOT_FOUND",
  "BAD_REQUEST",
  "PARSE_ERROR",
  "PRECONDITION_FAILED",
]);

function handler(req: Request) {
  return fetchRequestHandler({
    // ...
    onError({ error, path, type }) {
      if (CLIENT_ERROR_CODES.has(error.code)) return;

      Sentry.withScope((scope) => {
        scope.setTag("trpc.path", path);
        scope.setTag("trpc.type", type);
        scope.setTag("trpc.code", error.code);
        Sentry.captureException(error);
      });
    },
  });
}

Only errors that are NOT in CLIENT_ERROR_CODES get reported to Sentry. This means:

  • A user hitting a FORBIDDEN endpoint does not trigger a Sentry alert
  • A BAD_REQUEST from invalid input does not trigger an alert
  • An unhandled exception in a router handler (which becomes INTERNAL_SERVER_ERROR) DOES trigger an alert
  • Sentry events include the tRPC path, type, and error code as tags for filtering

Request Logging

The publicProcedure middleware logs every request with its outcome, path, type, and duration:

export const publicProcedure = t.procedure.use(async ({ ctx, path, type, next }) => {
  const start = performance.now();
  const result = await next();
  const durationMs = Math.round(performance.now() - start);

  if (result.ok) {
    ctx.logger.info({ path, type, durationMs }, "tRPC request completed");
  } else {
    ctx.logger.error(
      { path, type, durationMs, error: result.error.message },
      "tRPC request failed",
    );
  }

  return result;
});

Since every other procedure extends from this (directly or indirectly), all requests are logged regardless of auth status. The logger is request-scoped with userId and tenantId already bound from createContext.

Error Handling Summary by Layer

LayerWhat It CatchesAction
Zod .input()Malformed client inputAutomatic BAD_REQUEST with structured zodError
Procedure middlewareMissing session, no active org, non-memberUNAUTHORIZED, PRECONDITION_FAILED, FORBIDDEN
Router handlerCASL check failure, resource not found, business rulesExplicit TRPCError throw
publicProcedure loggingAll requests (success and failure)Structured log with path, type, duration
Route handler onErrorUnexpected server errorsSentry alert with tRPC tags

On this page