Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Handle errors properly in Convex - throw for exceptional cases, return null for expected cases, provide clear error messages
.claude/skills/kunanonj-cursor-plugin-convex-rule-error-handling-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 76% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 53% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 72% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 42% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 114% | 0% |
Proper error handling makes your app reliable and debuggable. Follow these patterns for Convex functions.
Throw when something unexpected happens or requirements aren't met:
typescriptexport const updateTask = mutation({ args: { taskId: v.id("tasks"), title: v.string() }, handler: async (ctx, args) => { // Authentication required (exceptional if missing) const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("Not authenticated"); } const user = await getCurrentUser(ctx); const task = await ctx.db.get(args.taskId); // Task must exist if (!task) { throw new Error("Task not found"); } // User must own the task if (task.userId !== user._id) { throw new Error("Unauthorized: You don't own this task"); } await ctx.db.patch(args.taskId, { title: args.title }); }, });
Return null when absence is a normal, expected possibility:
typescriptexport const getTask = query({ args: { taskId: v.id("tasks") }, returns: v.union( v.object({ _id: v.id("tasks"), title: v.string(), }), v.null() ), handler: async (ctx, args): Promise<Doc<"tasks"> | null> => { // Task might not exist - that's expected return await ctx.db.get(args.taskId); }, }); // Client handles null gracefully const task = useQuery(api.tasks.getTask, { taskId }); if (!task) { return <div>Task not found</div>; }
typescript// Specific, actionable, user-friendly throw new Error("Email already registered"); throw new Error("Invalid file type. Only PNG and JPG allowed"); throw new Error("Task limit reached (10 per user)"); throw new Error("Unauthorized: Admin access required"); throw new Error("Invalid coupon code");
typescript// Vague, unhelpful throw new Error("Error"); throw new Error("Failed"); throw new Error("Invalid input"); throw new Error("DB error"); throw new Error("Something went wrong");
Create structured errors for better handling:
typescript// convex/lib/errors.ts export class UnauthorizedError extends Error { constructor(message = "Unauthorized") { super(message); this.name = "UnauthorizedError"; } } export class NotFoundError extends Error { constructor(resource: string) { super(`${resource} not found`); this.name = "NotFoundError"; } } export class ValidationError extends Error { constructor(field: string, issue: string) { super(`${field}: ${issue}`); this.name = "ValidationError"; } } // Usage export const deleteTask = mutation({ handler: async (ctx, args) => { const user = await getCurrentUser(ctx); const task = await ctx.db.get(args.taskId); if (!task) { throw new NotFoundError("Task"); } if (task.userId !== user._id) { throw new UnauthorizedError("You don't own this task"); } await ctx.db.delete(args.taskId); }, });
typescript// ErrorBoundary.tsx import { Component, ReactNode } from "react"; class ConvexErrorBoundary extends Component< { children: ReactNode }, { hasError: boolean; error?: Error } > { state = { hasError: false, error: undefined }; static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } render() { if (this.state.hasError) { return ( <div> <h2>Something went wrong</h2> <p>{this.state.error?.message}</p> </div> ); } return this.props.children; } } // Usage <ConvexErrorBoundary> <TaskList /> </ConvexErrorBoundary>
typescriptconst createTask = useMutation(api.tasks.create); const handleCreate = async () => { try { await createTask({ title: "New task" }); toast.success("Task created!"); } catch (error) { if (error instanceof Error) { toast.error(error.message); } else { toast.error("Failed to create task"); } } };
typescript"use node"; import { action } from "./_generated/server"; import * as Sentry from "@sentry/node"; export const logError = action({ args: { error: v.string(), context: v.optional(v.any()), }, handler: async (ctx, args) => { // Log to Sentry Sentry.captureException(new Error(args.error), { extra: args.context, }); // Also log to Convex logs console.error("Application error:", args.error, args.context); }, });
typescriptexport const processPayment = mutation({ handler: async (ctx, args) => { try { const result = await processStripePayment(args); return result; } catch (error) { // Log error for debugging console.error("Payment processing failed:", { error: error instanceof Error ? error.message : "Unknown error", userId: ctx.user._id, amount: args.amount, }); // Re-throw with user-friendly message throw new Error("Payment failed. Please try again."); } }, });
Return structured validation errors:
typescriptexport const createUser = mutation({ args: { email: v.string(), age: v.number(), }, handler: async (ctx, args) => { // Validate email if (!args.email.includes("@")) { throw new Error("Invalid email address"); } // Validate age if (args.age < 18) { throw new Error("Must be 18 or older"); } // Check if email already exists const existing = await ctx.db .query("users") .withIndex("by_email", q => q.eq("email", args.email)) .unique(); if (existing) { throw new Error("Email already registered"); } return await ctx.db.insert("users", args); }, });
Actions can fail in different ways:
typescript"use node"; export const sendEmail = action({ args: { to: v.string(), subject: v.string() }, handler: async (ctx, args) => { try { const response = await fetch("https://api.sendgrid.com/v3/mail/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: args.to, from: "noreply@example.com", subject: args.subject, }), }); if (!response.ok) { const error = await response.text(); console.error("SendGrid error:", error); throw new Error("Failed to send email"); } return { success: true }; } catch (error) { // External API errors console.error("Email sending failed:", error); // Don't expose internal details to client throw new Error("Unable to send email. Please try again later."); } }, });
For transient failures:
typescriptasync function withRetry<T>( fn: () => Promise<T>, maxAttempts = 3, delayMs = 1000 ): Promise<T> { let lastError: Error; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error as Error; console.warn(`Attempt ${attempt} failed:`, lastError.message); if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delayMs * attempt)); } } } throw lastError!; } // Usage in action export const fetchExternalData = action({ handler: async (ctx) => { return await withRetry(async () => { const response = await fetch("https://api.example.com/data"); if (!response.ok) throw new Error("API request failed"); return await response.json(); }); }, });
Provide recovery options:
typescriptexport const updateProfile = mutation({ args: { name: v.string(), bio: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); // Validate name length if (args.name.length < 2) { throw new Error("Name must be at least 2 characters"); } if (args.name.length > 100) { throw new Error("Name must be less than 100 characters"); } // Check for profanity (example) if (containsProfanity(args.name)) { throw new Error("Name contains inappropriate content"); } await ctx.db.patch(user._id, { name: args.name, bio: args.bio, }); }, }); // Client can retry with different name const handleUpdate = async (name: string) => { try { await updateProfile({ name }); } catch (error) { // Show error, let user fix and retry setError(error.message); // Form stays populated for retry } };
typescriptexport const complexOperation = mutation({ handler: async (ctx, args) => { console.log("Starting operation", { userId: ctx.user._id, args }); const step1 = await doStep1(ctx); console.log("Step 1 complete", step1); const step2 = await doStep2(ctx, step1); console.log("Step 2 complete", step2); return step2; }, }); // View logs in Convex Dashboard
typescriptexport const processOrder = mutation({ handler: async (ctx, args) => { try { const order = await createOrder(ctx, args); await processPayment(ctx, order); await sendConfirmation(ctx, order); return order; } catch (error) { // Include context in error console.error("Order processing failed", { error: error instanceof Error ? error.message : "Unknown", orderId: order?._id, userId: ctx.user._id, step: "payment", // Which step failed }); throw error; } }, });
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 11,812 | 10,471 | -11% | 1 | 1 | 0% | 2,897 | 5,734 | +98% | 0 | 0 | — |
case-02 | fail→pass | 12,146 | 8,768 | -28% | 1 | 1 | 0% | 2,928 | 5,166 | +76% | 0 | 0 | — |
case-18 | pass→pass | 13,813 | 9,418 | -32% | 1 | 1 | 0% | 2,696 | 4,759 | +77% | 0 | 0 | — |
case-03 | pass→pass | 10,058 | 7,265 | -28% | 1 | 1 | 0% | 2,330 | 4,593 | +97% | 0 | 0 | — |
case-04 | pass→pass | 9,871 | 7,455 | -24% | 1 | 1 | 0% | 2,493 | 4,642 | +86% | 0 | 0 | — |
case-05 | fail→pass | 13,636 | 7,021 | -49% | 1 | 1 | 0% | 3,042 | 4,655 | +53% | 0 | 0 | — |
case-06 | pass→pass | 11,779 | 11,298 | -4% | 1 | 1 | 0% | 2,752 | 5,670 | +106% | 0 | 0 | — |
case-07 | pass→pass | 8,018 | 6,094 | -24% | 1 | 1 | 0% | 1,877 | 4,340 | +131% | 0 | 0 | — |
case-08 | fail→pass | 13,083 | 9,886 | -24% | 1 | 1 | 0% | 3,181 | 5,465 | +72% | 0 | 0 | — |
case-09 | fail→pass | 15,736 | 10,476 | -33% | 1 | 1 | 0% | 3,794 | 5,402 | +42% | 0 | 0 | — |
case-10 | pass→pass | 11,064 | 8,103 | -27% | 1 | 1 | 0% | 2,661 | 4,819 | +81% | 0 | 0 | — |
case-11 | pass→pass | 12,608 | 9,918 | -21% | 1 | 1 | 0% | 3,304 | 5,202 | +57% | 0 | 0 | — |
case-12 | pass→pass | 10,982 | 9,311 | -15% | 1 | 1 | 0% | 2,703 | 5,222 | +93% | 0 | 0 | — |
case-13 | fail→fail | 19,845 | 12,339 | -38% | 1 | 1 | 0% | 3,815 | 5,842 | +53% | 0 | 0 | — |
case-14 | fail→pass | 9,142 | 5,817 | -36% | 1 | 1 | 0% | 2,001 | 4,285 | +114% | 0 | 0 | — |
case-15 | pass→pass | 8,384 | 4,357 | -48% | 1 | 1 | 0% | 1,920 | 4,012 | +109% | 0 | 0 | — |
case-16 | pass→pass | 8,410 | 5,208 | -38% | 1 | 1 | 0% | 2,131 | 4,216 | +98% | 0 | 0 | — |
case-17 | pass→pass | 11,372 | 6,092 | -46% | 1 | 1 | 0% | 1,822 | 4,577 | +151% | 0 | 0 | — |
case-19 | pass→pass | 14,893 | 11,429 | -23% | 1 | 1 | 0% | 2,848 | 5,216 | +83% | 0 | 0 | — |
case-20 | pass→pass | 4,693 | 4,075 | -13% | 1 | 1 | 0% | 943 | 3,860 | +309% | 0 | 0 | — |
case-21 | fail→fail | 12,798 | 20,310 | +59% | 1 | 1 | 0% | 2,951 | 5,894 | +100% | 0 | 0 | — |
case-22 | pass→pass | 6,237 | 5,894 | -5% | 1 | 1 | 0% | 1,351 | 4,342 | +221% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.