BETTER-CONVEX

Error Handling

Throw typed errors with error codes and HTTP status mapping.

In this guide, we'll master error handling in cRPC. You'll learn to throw typed errors with error codes, use helper functions, and handle common error scenarios like authorization failures and rate limits.

Overview

cRPC provides CRPCError, a typed error class that extends Convex's ConvexError with tRPC-style error codes and HTTP status mapping.

FeatureBenefit
Typed error codesConsistent error handling across your app
HTTP status mappingAutomatic status codes for HTTP endpoints
Error chainingPreserve original error with cause
Helper functionsType guards and status code utilities

Let's explore how to use them.

Throwing Errors

The basic pattern is simple - import CRPCError and throw it with a code:

import { CRPCError } from 'better-convex/server';

throw new CRPCError({
  code: 'NOT_FOUND',
  message: 'Post not found',
});

Constructor

The CRPCError constructor accepts these parameters:

new CRPCError({
  code: CRPCErrorCode;  // Required - see table below
  message?: string;     // Optional, defaults to code
  cause?: unknown;      // Optional, original error
});

With Cause

When catching external errors, preserve the original stack trace using cause:

try {
  await externalApi.call();
} catch (error) {
  throw new CRPCError({
    code: 'INTERNAL_SERVER_ERROR',
    message: 'External API failed',
    cause: error,
  });
}

This helps with debugging by keeping the full error chain.

Error Codes

Here are all available error codes with their HTTP status mappings:

CodeDescriptionHTTP
BAD_REQUESTInvalid request or input validation failed400
UNAUTHORIZEDMissing or invalid authentication401
PAYMENT_REQUIREDPayment required to access resource402
FORBIDDENAuthenticated but not authorized403
NOT_FOUNDResource not found404
METHOD_NOT_SUPPORTEDMethod not supported405
TIMEOUTRequest timeout408
CONFLICTResource conflict409
PRECONDITION_FAILEDPrecondition failed412
PAYLOAD_TOO_LARGERequest too large413
UNPROCESSABLE_CONTENTValid syntax but cannot process422
TOO_MANY_REQUESTSRate limit exceeded429
INTERNAL_SERVER_ERRORUnexpected server error500

Tip: Use UNAUTHORIZED when the user isn't logged in, and FORBIDDEN when they're logged in but don't have permission.

Helper Functions

cRPC provides several helper functions for working with errors.

getCRPCErrorFromUnknown

Wrap unknown errors in CRPCError. Useful in catch blocks:

import { getCRPCErrorFromUnknown } from 'better-convex/crpc';

try {
  await riskyOperation();
} catch (error) {
  throw getCRPCErrorFromUnknown(error);
}

getHTTPStatusCodeFromError

Get the HTTP status code from an error. Useful for HTTP endpoints:

import { getHTTPStatusCodeFromError } from 'better-convex/crpc';

const httpStatus = getHTTPStatusCodeFromError(error); // 404

isCRPCError

Type guard for checking if an error is a CRPCError:

import { isCRPCError } from 'better-convex/crpc';

if (isCRPCError(error)) {
  console.log(error.code); // 'NOT_FOUND'
}

Common Patterns

Here are battle-tested error patterns you can copy into your project.

Authorization

Check authentication in middleware and throw UNAUTHORIZED:

convex/lib/crpc.ts
const authMiddleware = c.middleware(async ({ ctx, next }) => {
  const user = await getSessionUser(ctx);
  if (!user) {
    throw new CRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, user } });
});

Not Found

Throw NOT_FOUND when a resource doesn't exist:

convex/functions/posts.ts
export const getById = authQuery
  .input(z.object({ id: zid('posts') }))
  .query(async ({ ctx, input }) => {
    const post = await ctx.db.get(input.id);
    if (!post) {
      throw new CRPCError({
        code: 'NOT_FOUND',
        message: 'Post not found',
      });
    }
    return post;
  });
convex/functions/posts.ts
export const getById = authQuery
  .input(z.object({ id: zid('posts') }))
  .query(async ({ ctx, input }) => {
    const post = await ctx.table('posts').get(input.id);
    if (!post) {
      throw new CRPCError({
        code: 'NOT_FOUND',
        message: 'Post not found',
      });
    }
    return post;
  });

Rate Limiting

Throw TOO_MANY_REQUESTS when rate limits are exceeded:

if (isRateLimited) {
  throw new CRPCError({
    code: 'TOO_MANY_REQUESTS',
    message: 'Rate limit exceeded. Try again later.',
  });
}

Permission Check

Throw FORBIDDEN when the user doesn't have permission:

if (post.authorId !== ctx.userId) {
  throw new CRPCError({
    code: 'FORBIDDEN',
    message: 'You can only edit your own posts',
  });
}

Input Validation

Throw BAD_REQUEST for custom validation failures (beyond Zod validation):

if (input.startDate > input.endDate) {
  throw new CRPCError({
    code: 'BAD_REQUEST',
    message: 'Start date must be before end date',
  });
}

Migrate from Convex

If you're coming from vanilla Convex, here's what changes.

What stays the same

  • Errors are thrown in procedures
  • Errors propagate to the client

What's new

Before (vanilla Convex):

import { ConvexError } from 'convex/values';

throw new ConvexError('Post not found');
// or
throw new ConvexError({ message: 'Post not found' });

After (cRPC):

import { CRPCError } from 'better-convex/server';

throw new CRPCError({
  code: 'NOT_FOUND',
  message: 'Post not found',
});

Key differences:

  • Typed error codes instead of free-form messages
  • HTTP status code mapping for API responses
  • cause parameter for error chaining
  • Helper functions for error handling

Next Steps

On this page