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.
| Feature | Benefit |
|---|---|
| Typed error codes | Consistent error handling across your app |
| HTTP status mapping | Automatic status codes for HTTP endpoints |
| Error chaining | Preserve original error with cause |
| Helper functions | Type 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:
| Code | Description | HTTP |
|---|---|---|
BAD_REQUEST | Invalid request or input validation failed | 400 |
UNAUTHORIZED | Missing or invalid authentication | 401 |
PAYMENT_REQUIRED | Payment required to access resource | 402 |
FORBIDDEN | Authenticated but not authorized | 403 |
NOT_FOUND | Resource not found | 404 |
METHOD_NOT_SUPPORTED | Method not supported | 405 |
TIMEOUT | Request timeout | 408 |
CONFLICT | Resource conflict | 409 |
PRECONDITION_FAILED | Precondition failed | 412 |
PAYLOAD_TOO_LARGE | Request too large | 413 |
UNPROCESSABLE_CONTENT | Valid syntax but cannot process | 422 |
TOO_MANY_REQUESTS | Rate limit exceeded | 429 |
INTERNAL_SERVER_ERROR | Unexpected server error | 500 |
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); // 404isCRPCError
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:
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:
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;
});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
causeparameter for error chaining- Helper functions for error handling