Error handling
How the SHOPin storefront accelerator turns failures into a fixed set of HTTP statuses from the BFF and predictable UI handling in the presentation app. The global filter is HttpErrorFilter; request logging is described in Logging.
When debugging a failing flow, the UI and browser only show what is safe for users. Authoritative diagnostics for requests that reached the BFF are in BFF logs (one line per request, with correlation ID). See Finding and correlating errors.
Snippets below are patterns—imports and structure you mirror in integrations/*, apps/bff, and apps/presentation. For full modules and real call sites, use the linked GitHub paths.
Layers: integrations map vendor errors to Nest HTTP exceptions; the BFF applies BFF input validation for 400; the presentation uses Presentation input validation and BFF client helpers for user-visible errors.
Integration → BFF
Integrations call external APIs and map known failures to NotFoundException, ConflictException, UnauthorizedException, ForbiddenException. Unmapped errors (including failed Zod .parse() in integrations) surface as 500—see Integration validation. Integrations do not emit 400; that status is for BFF request validation only.
| Status | Nest exception | Examples in repo |
|---|---|---|
| 404 | NotFoundException | commercetools-api (order, product, …); mock-api (cart, line item). Helpers: isNotFoundError. |
| 409 | ConflictException | commercetools-api order; commercetools-auth register. Helper: isConflictError. |
| 401 | UnauthorizedException | commercetools-auth login, refresh |
| 403 | ForbiddenException | commercetools-auth login; BFF maps specific “Resource Owner Password Credentials Grant” cases to 403 |
import { ConflictException, NotFoundException } from '@nestjs/common'
// e.g. commercetools-api: `../helpers/is-not-found-error`
import { isConflictError, isNotFoundError } from '../helpers/is-not-found-error'
export async function createOrderExample() {
try {
// await external API…
} catch (error) {
if (isConflictError(error)) {
throw new ConflictException('Order already exists for this cart')
}
throw error
}
}
export async function getOrderExample(orderId: string) {
try {
// await external API…
} catch (error) {
if (isNotFoundError(error)) {
throw new NotFoundException(`Order not found: ${orderId}`)
}
throw error
}
}
Shape aligned with order.service.ts patterns (adjust imports to your integration).
BFF responses
HttpErrorFilter returns only the statuses below; anything else becomes 500. It attaches exceptions for pino-http (see Logging).
| Status | Response body |
|---|---|
| 400 | { statusCode: 400, message: 'Validation failed', issues: ZodIssue[] } |
| 401 | { statusCode: 401, message: 'Unauthorized' } |
| 403 | { statusCode: 403, message: 'Forbidden' } |
| 404 | { statusCode: 404, message: 'Not Found' } |
| 409 | { statusCode: 409, message: 'Conflict' } |
| 429 | { statusCode: 429, message: 'Too many requests' } |
| 500 | { statusCode: 500, message: 'Internal server error' } |
- 400 —
FrontendInputValidationExceptionfrom the Zod validation pipe only. BFF input validation. - 401–404, 409 — From thrown Nest HTTP exceptions (integration or BFF).
- 429 — Rate limiting when enabled. Rate limiting.
- 500 — Unhandled errors, integration Zod failures, or statuses not mapped by the filter. Integration validation.
Validate outgoing payloads with @core/contracts and .strip() where the codebase does today—see feature controllers (e.g. order.controller.ts).
Presentation app
BaseService throws an Error whose message matches HTTP status text (e.g. "409 Conflict") when !res.ok. For 403 with needsCsrf, it refetches the CSRF token and retries once.
| Status | Handling |
|---|---|
| 400 | handleErrorResponse / parseValidationErrorMessage for issues; auth flows use translation keys. |
| 401, 403 | HttpError.isAuthError; redirect to login with returnUrl where routes require auth. |
| 404 | HttpError.getStatusCode(error) === 404 or app/not-found.tsx for missing routes. |
| 409 | HttpError.isConflictError; e.g. checkout “order already exists”. |
| 429 | HttpError.isTooManyRequestsError or RateLimitError (Rate limiting). |
| 500 | Generic user-facing message; do not expose raw server text. |
Boundaries: app/error.tsx for runtime errors; protected routes redirect on 401/403 with returnUrl. Use the BFF client and React Query isError / error for UI.
import { HttpError } from '@/lib/error-utils'
export function onCheckoutConflictExample(
err: unknown,
handlers: {
invalidateCart: () => void
toast: (msg: string) => void
goHome: () => void
t: (key: string) => string
}
) {
if (HttpError.isConflictError(err)) {
handlers.invalidateCart()
handlers.toast(t('orderAlreadyExists'))
handlers.goHome()
}
}
Mirror the real hook use-create-checkout-order.ts for query client + router usage.
import { handleErrorResponse } from '@/lib/bff/utils/error-response'
import type { LoginRequest, LoginResponse } from '@core/contracts/auth/login'
// Inside a BaseService subclass — same pattern as auth-bff-service.ts
async login(request: LoginRequest): Promise<LoginResponse> {
return await this.post<LoginResponse>('/auth/login', request, {
onError: (res) => handleErrorResponse<LoginResponse>(res),
})
}
Contracts and optional fields
Keep contracts strict enough for correctness; use optional fields where the UI can omit or skeleton. The BFF remains the source of truth—do not treat client validation as a security boundary.
Related
- Finding and correlating errors
- Logging
- Rate limiting
- Input validation
- BFF communication
- Maintain contracts
Back to Validation & resilience · Back to How to work with SHOPin