SHOPin Logo
Skip to main documentation content

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.

StatusNest exceptionExamples in repo
404NotFoundExceptioncommercetools-api (order, product, …); mock-api (cart, line item). Helpers: isNotFoundError.
409ConflictExceptioncommercetools-api order; commercetools-auth register. Helper: isConflictError.
401UnauthorizedExceptioncommercetools-auth login, refresh
403ForbiddenExceptioncommercetools-auth login; BFF maps specific “Resource Owner Password Credentials Grant” cases to 403
TypeScript
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).

StatusResponse 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' }

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.

StatusHandling
400handleErrorResponse / parseValidationErrorMessage for issues; auth flows use translation keys.
401, 403HttpError.isAuthError; redirect to login with returnUrl where routes require auth.
404HttpError.getStatusCode(error) === 404 or app/not-found.tsx for missing routes.
409HttpError.isConflictError; e.g. checkout “order already exists”.
429HttpError.isTooManyRequestsError or RateLimitError (Rate limiting).
500Generic 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.

TypeScript
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.

TypeScript
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


Back to Validation & resilience · Back to How to work with SHOPin