Skip to main content

Error types

ConvexCallError, its codes, and the functions that create, check, and serialize it.

ts
import {
  ConvexCallError,
  isConvexCallError,
  isSerializedConvexCallError,
  normalizeConvexError,
  type ConvexCallErrorCode,
  type ConvexCallErrorInput,
  type ConvexCallErrorKind,
  type ConvexCallOutcome,
  type ConvexUploadPhase,
  type SerializedConvexCallError,
} from '@lupinum/better-convex-nuxt/errors'

Vue applications without Nuxt import the same names from @lupinum/better-convex-vue/errors. Both entries export the same class and functions. You can import them anywhere: in components, Nitro routes, and tests.

ConvexCallErrorKind

ts
type ConvexCallErrorKind = 'authentication' | 'transport' | 'server' | 'unknown'
KindSource
authenticationMissing required identity, a changed identity, or a failed token exchange
transportA library-owned HTTP boundary: network failure, timeout, abort, or an unusable response
serverA Convex application error thrown with ConvexError
unknownAnything else, including failures that the library raises when you call an API wrongly

The kind never depends on the message text.

ConvexCallError

FieldType
name'ConvexCallError'
kindConvexCallErrorKind
messagestring
codestring | undefined
statusnumber | undefined
dataunknown
functionNamestring | undefined
outcomeConvexCallOutcome | undefined
phaseConvexUploadPhase | undefined

functionName is the Convex function path, for example notes:create. Composables, serverConvex, server-rendered queries, and uploads set it.

code is a ConvexCallErrorCode for a failure that Better Convex raises itself. For a Convex application error, it is the application's data.code.

ConvexCallError has no cause. The original error object is not kept, so it cannot leak through logging, JSON, or the server-rendered payload.

Construct one with new ConvexCallError(input), where input is a ConvexCallErrorInput: kind and message are required, and code, status, data, functionName, outcome, and phase are optional. toJSON() returns a SerializedConvexCallError.

Message

The message of a Convex application error is the text that the developer wrote:

  1. data, when data is a non-empty string;
  2. else data.message, when it is a non-empty string;
  3. else the generic text Convex application error.
convex/projects.ts
throw new ConvexError({ code: 'PROJECT_ARCHIVED', message: 'Archived projects cannot be edited' })

The browser receives error.message === 'Archived projects cannot be edited' and error.code === 'PROJECT_ARCHIVED'. Write this text for the user who sees it. Convex's own wire message can contain function stack frames, so it is never copied. Every other unclassified value gets the generic text Unknown Convex error.

Outcome

ts
type ConvexCallOutcome = 'not-sent' | 'unknown'

outcome tells what happened to the request that failed:

ValueMeaning
not-sentThe request never reached the network. Nothing that it would write exists.
unknownThe request was sent, but no result arrived. It may have run.
undefinedThe failure is not about sending, for example a ConvexError that your function threw

The code that sent the request, or did not send it, records outcome. It is never derived from code. For example, IDENTITY_CHANGED has not-sent when the user changed before the call was sent, and unknown when the user changed while it was in flight. A lost storage connection during an upload (NETWORK_ERROR) is unknown.

outcome does not make a retry safe. See Idempotent writes.

Phase

ts
type ConvexUploadPhase = 'prepare' | 'upload' | 'complete'

useConvexFileUpload sets phase to the upload step that failed: prepare (the upload-URL mutation), upload (the storage request), or complete (the complete option). Every earlier step succeeded. A check that runs before any request, such as FILE_TOO_LARGE, has no phase. With a phase, outcome describes the whole step: not-sent means that the step sent no request, and a complete step that sent one call and then failed before the next has outcome: 'unknown'. See Upload files.

ConvexCallErrorCode

ts
type ConvexCallErrorCode =
  | 'IDENTITY_CHANGED'
  | 'CANCELLED'
  | 'FILE_TOO_LARGE'
  | 'FILE_TYPE_NOT_ALLOWED'
  | 'UPLOAD_IN_PROGRESS'
  | 'SUBMIT_IN_PROGRESS'
  | 'UNAUTHENTICATED'
  | 'CLIENT_UNAVAILABLE'
  | 'NETWORK_ERROR'
  | 'TIMEOUT'
  | 'RESPONSE_TOO_LARGE'
  | 'UPSTREAM_ERROR'
  | 'INVALID_RESPONSE'
  | 'INVALID_UPLOAD_URL'
  | 'CONVEX_URL_MISSING'
  | 'SITE_URL_MISSING'
  | 'AUTH_UNAVAILABLE'
  | 'AUTH_CONFIRMATION_TIMEOUT'
  | 'PAGINATION_SPLIT_REQUIRED'
CodeRaised when
IDENTITY_CHANGEDThe signed-in identity changed before or while the call was sent. See outcome.
CANCELLEDcancel(), reset(), or a disposed scope stopped the work
FILE_TOO_LARGEAn upload is larger than maxSize (useConvexFileUpload or op.upload)
FILE_TYPE_NOT_ALLOWEDAn upload does not match allowedTypes (useConvexFileUpload or op.upload)
UPLOAD_IN_PROGRESSA second upload started while the first one is pending
SUBMIT_IN_PROGRESSA second form submission started while the first one is pending
UNAUTHENTICATEDA server operation requires a signed-in identity, for example requireConvexUser or auth: 'required'
CLIENT_UNAVAILABLENo browser Convex client exists, for example during server rendering
NETWORK_ERRORA library-owned HTTP request, such as the storage upload or a serverConvex call, could not complete
TIMEOUTA serverConvex request exceeded its deadline
RESPONSE_TOO_LARGEA serverConvex response exceeded server.maxResponseBytes
UPSTREAM_ERRORThe upload endpoint, the Convex HTTP API, or the token exchange answered with a failure status
INVALID_RESPONSEThe upload endpoint or the token exchange answered with an unusable body
INVALID_UPLOAD_URLThe upload-URL mutation, or the url option, did not give a non-empty URL
CONVEX_URL_MISSINGserverConvex has no configured Convex URL
SITE_URL_MISSINGgetConvexUser or requireConvexUser runs with auth enabled but no Convex site URL
AUTH_UNAVAILABLEgetConvexUser or requireConvexUser cannot resolve the identity because the auth backend failed
AUTH_CONFIRMATION_TIMEOUTConvex did not confirm a new authentication token in time
PAGINATION_SPLIT_REQUIREDA paginated page must be split into bounded pages before it can be shown

After IDENTITY_CHANGED with outcome: 'unknown', do not assume that a retry is safe. The first call may have written data.

isConvexCallError

ts
isConvexCallError(error: unknown, code?: ConvexCallErrorCode | string): error is ConvexCallError

Returns true when error is a ConvexCallError and, when you pass code, its code equals code. Pass an application code to match your own ConvexError codes:

ts
try {
  await mutate({ projectId })
} catch (error) {
  if (isConvexCallError(error, 'PROJECT_ARCHIVED')) showArchivedNotice()
  else if (isConvexCallError(error, 'IDENTITY_CHANGED')) return
  else throw error
}

The guard does not revive serialized values. Pass a value from $fetch, useFetch, or another boundary through normalizeConvexError first.

normalizeConvexError

ts
normalizeConvexError(error: unknown, context?: { functionName?: string }): ConvexCallError
  • An existing ConvexCallError passes through unchanged, so a transport or authentication error is never downgraded. When context.functionName is set and the error has no functionName, a copy with it is returned.
  • A Convex application error becomes server. data is kept verbatim, code and status come from data.code and data.status, and message follows the message rule.
  • A serialized ConvexCallError is revived when it is the value itself, its .data, or its .data.data. This covers an H3 error from toConvexH3Error, its JSON response body, and the FetchError from $fetch or useFetch.
  • Everything else becomes unknown with the message Unknown Convex error.

context.functionName only fills a missing function name. It never replaces one.

Revive the error from a Nitro route in the browser:

ts
try {
  await $fetch('/api/reports', { method: 'POST', body: { projectId } })
} catch (raw) {
  const error = normalizeConvexError(raw)
  if (isConvexCallError(error, 'UNAUTHENTICATED')) await navigateTo('/auth/signin')
}

Serialized guard

ts
isSerializedConvexCallError(value: unknown): value is SerializedConvexCallError

normalizeConvexError uses this check before it turns a value back into a ConvexCallError. It accepts only a plain object with the keys below and no other keys, a known kind, and a string message. When present, code and functionName must be non-empty strings, status must be a finite number, and outcome and phase must be known values. name: 'ConvexCallError' alone is not enough. A value with an extra key, such as stack, fails the check.

ts
interface SerializedConvexCallError {
  name: 'ConvexCallError'
  kind: ConvexCallErrorKind
  message: string
  code?: string
  status?: number
  data?: unknown
  functionName?: string
  outcome?: ConvexCallOutcome
  phase?: ConvexUploadPhase
}

Use the check when you send errors through your own serialization. The module already restores ConvexCallError values in the Nuxt payload, with functionName.

Idempotent writes

A write can reach Convex twice: a user presses "Try again" after outcome: 'unknown', a double click, or a page reload during a request. outcome tells you that a write may have run. Only your Convex function can make a second run harmless.

Give each user action a request ID. The mutation stores it with the result and returns the stored result when the same ID arrives again:

convex/schema.ts
payments: defineTable({
  ownerId: v.string(),
  requestId: v.string(),
  amountCents: v.number(),
}).index('by_owner_request', ['ownerId', 'requestId']),
convex/payments.ts
import { v } from 'convex/values'
import { mutation } from './_generated/server'
import { auth } from './auth'

export const create = mutation({
  args: { requestId: v.string(), amountCents: v.number() },
  handler: async (ctx, { requestId, amountCents }) => {
    const user = await auth.requireUser(ctx)
    const existing = await ctx.db
      .query('payments')
      .withIndex('by_owner_request', (q) => q.eq('ownerId', user.id).eq('requestId', requestId))
      .unique()
    if (existing) return existing._id
    return await ctx.db.insert('payments', { ownerId: user.id, requestId, amountCents })
  },
})

In the component, keep the request ID until the write succeeds, so that "Try again" sends the same ID:

ts
const { mutate: createPayment } = useConvexMutation(api.payments.create)
let requestId = crypto.randomUUID()

async function pay(amountCents: number) {
  await createPayment({ requestId, amountCents })
  requestId = crypto.randomUUID()
}

Key the request ID by the user, as the index above does. Then a request ID of one user never returns the result of another user. Remove old request IDs with a scheduled function when your product no longer needs them.

Nitro responses

Use toConvexH3Error(error) from @lupinum/better-convex-nuxt/server to turn any error into an H3 error. Its data is the serialized ConvexCallError without functionName, which stays on the server. See the server API.

Form errors

useConvexForm reports failures as a ConvexFormError, not a ConvexCallError. It has kind ('validation' or 'submission'), message, issues, fieldErrors, formError, and callError. For a submission error, callError is the ConvexCallError of the mutation. Import ConvexFormError from @lupinum/better-convex-nuxt/errors in Nuxt, or from @lupinum/better-convex-vue/errors in plain Vue. See useConvexForm.