How to Handle Errors Gracefully in Medusa API Routes

Medusa handles errors gracefully by routing all thrown MedusaError instances and Zod validation failures through a centralized error-handler middleware that automatically maps error types to HTTP status codes and returns standardized JSON responses.

Medusa's HTTP layer separates error generation from error delivery through a layered architecture that eliminates repetitive try-catch blocks in individual routes. When working with the medusajs/medusa codebase, you throw domain-specific errors from services or workflows and let the core framework transform them into consistent HTTP responses. This approach ensures that every API endpoint returns predictable error payloads without cluttering route handlers with boilerplate error-handling logic.

Understanding Medusa's Layered Error Architecture

Medusa employs a three-stage pipeline for error management: generation, formatting, and delivery. Domain logic throws MedusaError objects, the framework normalizes and enriches these exceptions, and a centralized middleware sends the final HTTP response. This separation keeps your API routes clean while guaranteeing uniform error semantics across the entire application.

Error Generation with MedusaError

Domain errors originate in services, repositories, or workflow steps using the MedusaError class from @medusajs/utils. The constructor accepts a type from the MedusaError.Types enum (NOT_FOUND, INVALID_DATA, CONFLICT, UNEXPECTED_STATE, etc.) and a descriptive message.

import { MedusaError } from "@medusajs/utils"

// Example in a service method
if (!order) {
  throw new MedusaError(
    MedusaError.Types.NOT_FOUND,
    `Order with id ${orderId} not found`
  )
}

Validation errors follow a similar path. When Zod validators fail, the framework automatically transforms the resulting ZodError into a MedusaError before it reaches your route handler.

The Centralized Error-Handler Middleware

The core error-handling logic lives in packages/core/framework/src/http/middlewares/error-handler.ts (lines 44-71). This middleware performs several critical functions:

  • Scope Resolution: Retrieves the Medusa logger from the request scope using req.scope.resolve(ContainerRegistrationKeys.LOGGER) or falls back to console
  • Exception Formatting: Calls formatException(err) to normalize any thrown value into a MedusaError structure
  • Status-Code Mapping: Switches on err.type and err.name to select appropriate HTTP status codes (e.g., 404 for NOT_FOUND, 400 for INVALID_DATA, 409 for CONFLICT)
  • Intelligent Logging: Logs errors at the error level for 5xx status codes and info level for client errors (4xx)
  • Zod Issue Processing: If the error contains an issues array, each issue is converted to a human-readable message via fromZodIssue and returned as a 400 Bad Request payload
  • JSON Response: Sends a standardized payload matching the OpenAPI Error schema: { code, type, message }

A thin wrapper re-exports this middleware for the main medusa package at packages/medusa/src/utils/middlewares/error-handler.ts.

Implementing Error Handling in API Routes

API routes in Medusa are plain async functions that do not require explicit try-catch blocks. The Express application registers the error handler via app.use(errorHandler()) in packages/core/framework/src/http/router.ts, ensuring all thrown errors bubble up to the centralized handler.

Automatic Error Propagation

Consider the admin orders route at packages/medusa/src/api/admin/orders/route.ts. The handler invokes a workflow that may throw MedusaError if resources are missing:

// packages/medusa/src/api/admin/orders/route.ts
export const GET = async (
  req: AuthenticatedMedusaRequest<HttpTypes.AdminOrderFilters>,
  res: MedusaResponse<HttpTypes.AdminOrderListResponse>
) => {
  const variables = {
    filters: { ...req.filterableFields, is_draft_order: false },
    ...req.queryConfig.pagination,
  }

  const workflow = getOrdersListWorkflow(req.scope)
  const { result } = await workflow.run({
    input: { fields: req.queryConfig.fields, variables },
  })

  const { rows, metadata } = result as { rows: OrderDTO[]; metadata: any }

  res.json({
    orders: rows as unknown as HttpTypes.AdminOrder[],
    count: metadata.count,
    offset: metadata.skip,
    limit: metadata.take,
  })
}

If the workflow throws new MedusaError(MedusaError.Types.NOT_FOUND, "Order not found"), the middleware automatically responds with:

{
  "code": "not_found",
  "type": "not_found",
  "message": "Order not found"
}

Optional Custom Error Enrichment

When you need to add context before the global handler processes an error, catch the exception, enrich it, and re-throw:

import { MedusaError } from "@medusajs/utils"

export const DELETE = async (req, res) => {
  try {
    await someService.delete(req.params.id)
    res.status(200).json({ success: true })
  } catch (e) {
    // Convert unknown errors to a generic internal error
    const err = e instanceof MedusaError
      ? e
      : new MedusaError(MedusaError.Types.UNEXPECTED_STATE, "Unexpected failure")

    // Attach extra context for debugging
    err.message = `Failed to delete ${req.params.id}: ${err.message}`
    throw err   // Delegate to core middleware for final response formatting
  }
}

Status Code Mapping Reference

The error-handler middleware maps MedusaError.Types to HTTP status codes as implemented in packages/core/framework/src/http/middlewares/error-handler.ts:

  • NOT_FOUND → 404 Not Found
  • INVALID_DATA → 400 Bad Request
  • CONFLICT → 409 Conflict
  • UNAUTHORIZED → 401 Unauthorized
  • NOT_ALLOWED → 403 Forbidden
  • UNEXPECTED_STATE → 500 Internal Server Error (catch-all for system failures)

Zod validation errors automatically map to 400 Bad Request with detailed issue arrays.

Summary

  • Throw MedusaError from services, repositories, and workflows using specific types like NOT_FOUND or INVALID_DATA to trigger appropriate HTTP responses
  • The centralized middleware at packages/core/framework/src/http/middlewares/error-handler.ts handles all formatting, logging, and JSON serialization automatically
  • API routes require no explicit try-catch blocks; errors bubble up from any layer of the application and reach the global handler via Express middleware registration
  • Zod validation errors are automatically transformed into 400 Bad Request responses with human-readable messages via the exception formatter
  • For advanced scenarios, catch errors in your route handler to enrich context, then re-throw to maintain the standardized error response format

Frequently Asked Questions

What happens if I throw a standard JavaScript Error instead of MedusaError?

The middleware's formatException function normalizes unknown errors into MedusaError instances with type UNEXPECTED_STATE. While the request will still fail with a 500 status code, you lose the semantic precision and proper status-code mapping that explicit MedusaError types provide. Always use MedusaError for domain-specific failures to ensure consistent API contracts.

How does Medusa convert Zod validation errors into HTTP responses?

Zod errors are intercepted by the exception formatter (packages/core/framework/src/http/middlewares/exception-formatter.ts) and transformed into MedusaError instances with type INVALID_DATA. The error handler then maps these to 400 Bad Request responses, using fromZodIssue to convert each Zod issue into a human-readable message in the response payload.

Can I customize the HTTP status code for a specific error scenario?

The status-code mapping is hardcoded in the core error-handler middleware. To return a custom status code, catch the error in your route handler, inspect the condition, and throw a different MedusaError type that maps to your desired code, or manually send the response and terminate the request. Re-throwing ensures you maintain the standardized JSON payload structure defined by the framework.

Where is the error-handler middleware registered in the Medusa application lifecycle?

The middleware is registered via app.use(errorHandler()) within the core HTTP router initialization at packages/core/framework/src/http/router.ts. This registration occurs before route definitions, ensuring that all admin and store API endpoints pass through the centralized error handling logic before returning responses to the client.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →