Skip to main content

Error Handling

GTM Clarity uses tRPC's structured error system. All errors are returned as typed TRPCError objects with a code, message, and optional cause. This page documents the error codes, common scenarios, and client-side handling patterns.

Error Response Structure

All tRPC errors follow this shape:

{
"error": {
"message": "Not signed in",
"code": -32001,
"data": {
"code": "UNAUTHORIZED",
"httpStatus": 401,
"path": "scoring.getMatrixData"
}
}
}

Error Codes

tRPC CodeHTTP StatusWhen It Occurs
UNAUTHORIZED401No valid Clerk session -- user is not signed in
FORBIDDEN403No organization selected, or insufficient role
NOT_FOUND404Requested entity does not exist within the tenant
BAD_REQUEST400Input validation failed (Zod) or invalid parameters
INTERNAL_SERVER_ERROR500Unexpected server error

UNAUTHORIZED

Returned when the isAuthed middleware cannot find a valid Clerk userId in the session.

// src/server/trpc/init.ts
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.auth.userId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not signed in" });
}
return next({ ctx: { ...ctx, userId: ctx.auth.userId } });
});

Common causes:

  • Session expired
  • User not logged in
  • Clerk middleware not configured

Client handling:

import { useQuery } from "@tanstack/react-query";
import { TRPCClientError } from "@trpc/client";

function ScoringPage() {
const trpc = useTRPC();
const { data, error } = useQuery(
trpc.scoring.getMatrixData.queryOptions({ limit: 500 })
);

if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
return <RedirectToLogin />;
}
}

return <ScoreMatrix data={data} />;
}

FORBIDDEN

Returned in two scenarios:

1. No organization selected -- the hasTenant middleware requires an active org:

if (!ctx.auth.orgId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "No organization selected",
});
}

2. Insufficient role -- the isAdmin middleware checks for org:admin:

if (!ctx.auth.has({ role: "org:admin" })) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}

Common causes:

  • User has not selected an organization in Clerk
  • User has org:viewer or org:manager role but called an admin-only procedure
  • Organization membership was revoked
tip

Check the message field to distinguish between "no org" and "insufficient role" cases:

  • "No organization selected" -- prompt the user to select an org
  • "Admin access required" -- show a permission denied message

NOT_FOUND

Returned when a requested resource does not exist or does not belong to the current tenant.

throw new TRPCError({
code: "NOT_FOUND",
message: "Scored entity not found",
});

Procedures that throw NOT_FOUND:

RouterProcedureWhen
scoringgetEntityDetailEntity type + ID has no score record
buyingGroupgetDetailBuying group ID not found in tenant
buyingGroupgetMembersBuying group ID not found in tenant
buyingGroupgetMemberEngagementBuying group ID not found in tenant
buyingGroupgetCandidatesBuying group ID not found in tenant
buyingGroupconfirmMemberRoleMember ID not found in tenant
fieldMappinglistSourceFieldsConnector not found or not connected
writebacksaveConfigConnector not found in tenant
writebacklistWriteableFieldsConnector not found or not connected

BAD_REQUEST

Returned when Zod input validation fails or when business logic rejects the input.

Zod Validation Errors

When a procedure input fails Zod validation, tRPC wraps the Zod issues into a BAD_REQUEST error:

{
"error": {
"message": "[\n {\n \"code\": \"invalid_type\",\n \"expected\": \"string\",\n \"received\": \"undefined\",\n \"path\": [\"entityType\"],\n \"message\": \"Required\"\n }\n]",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"path": "scoring.getEntityDetail"
}
}
}

Client handling for validation errors:

import { TRPCClientError } from "@trpc/client";

function handleError(error: unknown) {
if (error instanceof TRPCClientError) {
if (error.data?.code === "BAD_REQUEST") {
// Parse Zod issues from the message
try {
const issues = JSON.parse(error.message);
const fieldErrors = issues.map(
(i: { path: string[]; message: string }) =>
`${i.path.join(".")}: ${i.message}`
);
return fieldErrors;
} catch {
return [error.message];
}
}
}
return ["An unexpected error occurred"];
}

Business Logic Errors

Some procedures throw BAD_REQUEST for domain-specific validation:

RouterProcedureScenario
fieldMappingsaveMappingsRequired canonical fields missing from mappings
fieldMappinglistSourceFieldsUnsupported entity type

Example:

throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid mappings: Missing required field: externalId",
});

INTERNAL_SERVER_ERROR

Returned for unexpected failures such as database errors, network timeouts, or unhandled exceptions. The actual error details are logged server-side but not exposed to the client.

Client handling:

if (error instanceof TRPCClientError) {
if (error.data?.code === "INTERNAL_SERVER_ERROR") {
return <ErrorPage message="Something went wrong. Please try again." />;
}
}
warning

Never expose internal error details (stack traces, SQL errors) to the client. The tRPC layer ensures only the code and a generic message are returned.


Global Error Handling Pattern

Set up a global error handler using TanStack Query's QueryClient configuration:

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof TRPCClientError) {
// Don't retry auth or validation errors
const code = error.data?.code;
if (code === "UNAUTHORIZED" || code === "FORBIDDEN" || code === "BAD_REQUEST") {
return false;
}
}
return failureCount < 3;
},
},
mutations: {
retry: false,
},
},
});
Retry Semantics
  • Queries retry up to 3 times for transient errors (network, 500s)
  • Mutations should not retry automatically to avoid duplicate writes
  • Auth/validation errors should never retry -- they require user action