Errors are responses

Not all requests succeed. Some fail because of invalid input. Some fail because of authorization. Some fail because of bugs.

In Hectoday HTTP, expected failures are responses. Only unexpected failures are exceptions.

Errors are not exceptions

Most frameworks blur the line between expected and unexpected failures:

// In many frameworks
app.post("/users", (req, res) => {
  // Is this expected or unexpected?
  if (!req.body.email) {
    throw new ValidationError("Email required"); // → 400
  }
  
  // Is this expected or unexpected?
  if (!req.user) {
    throw new UnauthorizedError(); // → 401
  }
  
  // Is this expected or unexpected?
  const user = await db.users.create(req.body); // Might throw → 500
});

When you throw, you’re saying: “Something went wrong, framework figure it out.”

The framework maps exceptions to status codes. ValidationError becomes 400. UnauthorizedError becomes 401. Database errors become 500.

This is implicit. You can’t tell from reading the handler what status codes it returns.

Hectoday HTTP’s model

In Hectoday HTTP, there are two types of failures:

Expected failures: Return a Response explicitly

route.post("/users", {
  resolve: async (c) => {
    // Expected failure: invalid input
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    // Expected failure: unauthorized
    if (!c.locals.user) {
      return Response.json({ error: "Unauthorized" }, { status: 401 });
    }
    
    // Expected failure: duplicate email
    const existing = await db.users.findByEmail(c.input.body.email);
    if (existing) {
      return Response.json({ error: "Email already exists" }, { status: 409 });
    }
    
    // Success
    const user = await db.users.create(c.input.body);
    return Response.json(user, { status: 201 });
  }
})

Unexpected failures: Throw (goes to onError)

route.get("/users", {
  resolve: async (c) => {
    // If this throws, it's unexpected (bug, infrastructure failure)
    const users = await db.users.getAll();
    return Response.json(users);
  }
})

The distinction

Expected failure:

Unexpected failure:

Why throwing breaks the mental model

When you throw to indicate expected failures, you hide control flow:

// Where does this request end?
function handler() {
  validateInput(data); // Might throw → 400
  checkAuth(token);    // Might throw → 401
  checkPermissions();  // Might throw → 403
  
  // Are we here? Or did we already return?
  const result = process();
  return Response.json(result);
}

You can’t tell by reading this code:

With explicit returns, everything is visible:

route.post("/users", {
  resolve: async (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
      // ↑ Request ends here, returns 400
    }
    
    if (!c.locals.user) {
      return Response.json({ error: "Unauthorized" }, { status: 401 });
      // ↑ Request ends here, returns 401
    }
    
    if (c.locals.user.role !== "admin") {
      return Response.json({ error: "Forbidden" }, { status: 403 });
      // ↑ Request ends here, returns 403
    }
    
    // If we're here, all checks passed
    const result = await process(c.input.body);
    return Response.json(result, { status: 201 });
    // ↑ Request ends here, returns 201
  }
})

Every return is visible. You can trace the control flow. You can see exactly what responses this handler returns.

Modeling failure

Different types of failures need different responses.

Invalid input

When the client sends malformed or invalid data:

route.post("/users", {
  request: {
    body: z.object({
      name: z.string().min(1).max(100),
      email: z.string().email(),
      age: z.number().int().min(0).max(150)
    })
  },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json(
        {
          error: "Validation failed",
          issues: c.input.issues
        },
        { status: 400 }
      );
    }
    
    // Input is valid
    const user = await db.users.create(c.input.body);
    return Response.json(user, { status: 201 });
  }
})

Status code: 400 Bad Request

Body: Explains what’s wrong with the input:

{
  "error": "Validation failed",
  "issues": [
    {
      "part": "body",
      "path": ["email"],
      "message": "Invalid email address"
    },
    {
      "part": "body",
      "path": ["age"],
      "message": "Number must be less than or equal to 150"
    }
  ]
}

Unauthorized access

When the client isn’t authenticated:

const requireAuth: GuardFn = (c) => {
  const token = c.request.headers.get("authorization");
  
  if (!token) {
    return {
      deny: Response.json(
        { error: "Missing authentication token" },
        { status: 401 }
      )
    };
  }
  
  const user = verifyToken(token);
  
  if (!user) {
    return {
      deny: Response.json(
        { error: "Invalid or expired token" },
        { status: 401 }
      )
    };
  }
  
  return { allow: true, locals: { user } };
};

Status code: 401 Unauthorized

Body: Explains why authentication failed:

{
  "error": "Invalid or expired token"
}

You might also include helpful information:

{
  "error": "Missing authentication token",
  "hint": "Include an Authorization header with a valid token",
  "docs": "https://example.com/docs/authentication"
}

Forbidden access

When the client is authenticated but not authorized:

const requireAdmin: GuardFn = (c) => {
  const user = c.locals.user;
  
  if (!user || user.role !== "admin") {
    return {
      deny: Response.json(
        { error: "Admin access required" },
        { status: 403 }
      )
    };
  }
  
  return { allow: true };
};

Status code: 403 Forbidden

Body: Explains what permission is required:

{
  "error": "Admin access required"
}

Not found

When the requested resource doesn’t exist:

route.get("/users/:id", {
  resolve: async (c) => {
    const id = c.raw.params.id;
    const user = await db.users.get(id);
    
    if (!user) {
      return Response.json(
        { error: "User not found" },
        { status: 404 }
      );
    }
    
    return Response.json(user);
  }
})

Status code: 404 Not Found

Body: Explains what wasn’t found:

{
  "error": "User not found"
}

You might include the ID that wasn’t found:

{
  "error": "User not found",
  "userId": "123"
}

Conflict

When the request conflicts with current state:

route.post("/users", {
  resolve: async (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    const { email } = c.input.body;
    const existing = await db.users.findByEmail(email);
    
    if (existing) {
      return Response.json(
        {
          error: "Email already exists",
          field: "email",
          value: email
        },
        { status: 409 }
      );
    }
    
    const user = await db.users.create(c.input.body);
    return Response.json(user, { status: 201 });
  }
})

Status code: 409 Conflict

Body: Explains the conflict:

{
  "error": "Email already exists",
  "field": "email",
  "value": "[email protected]"
}

Unprocessable entity

When the input is valid but semantically incorrect:

route.post("/transfers", {
  request: {
    body: z.object({
      fromAccount: z.string(),
      toAccount: z.string(),
      amount: z.number().positive()
    })
  },
  resolve: async (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    const { fromAccount, toAccount, amount } = c.input.body;
    
    // Input is valid, but business rules say this is wrong
    if (fromAccount === toAccount) {
      return Response.json(
        { error: "Cannot transfer to the same account" },
        { status: 422 }
      );
    }
    
    const balance = await db.accounts.getBalance(fromAccount);
    
    if (balance < amount) {
      return Response.json(
        {
          error: "Insufficient funds",
          available: balance,
          requested: amount
        },
        { status: 422 }
      );
    }
    
    const transfer = await db.transfers.create({ fromAccount, toAccount, amount });
    return Response.json(transfer, { status: 201 });
  }
})

Status code: 422 Unprocessable Entity

Body: Explains why the business logic rejected it:

{
  "error": "Insufficient funds",
  "available": 100.50,
  "requested": 200.00
}

Internal errors

When something unexpected happens (bugs, infrastructure failures):

route.get("/users", {
  resolve: async (c) => {
    try {
      const users = await db.users.getAll();
      return Response.json(users);
    } catch (error) {
      // Log the error
      console.error("Failed to fetch users:", error);
      
      // Return generic error to client
      return Response.json(
        { error: "Failed to fetch users" },
        { status: 500 }
      );
    }
  }
})

Or let it throw and handle in onError:

const app = setup({
  handlers: [...],
  
  onError: ({ error, context }) => {
    // Log with context
    console.error("Unexpected error:", {
      error,
      method: context.request.method,
      url: context.request.url,
      user: context.locals.user?.id
    });
    
    // Return sanitized error to client
    return Response.json(
      {
        error: "Internal server error",
        requestId: context.locals.requestId
      },
      { status: 500 }
    );
  }
});

Status code: 500 Internal Server Error

Body: Generic message (don’t leak implementation details):

{
  "error": "Internal server error",
  "requestId": "abc-123"
}

Never expose stack traces or internal details to clients in production.

Returning errors explicitly

Errors are just Responses. Return them like any other response.

Status codes as facts

Status codes communicate what kind of failure occurred:

CodeMeaningWhen to Use
400Bad RequestClient sent invalid/malformed data
401UnauthorizedClient needs to authenticate
403ForbiddenClient is authenticated but not authorized
404Not FoundResource doesn’t exist
409ConflictRequest conflicts with current state
422Unprocessable EntityValid input, but business rules reject it
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server failure
503Service UnavailableServer temporarily can’t handle request

Use the right status code for the situation:

route.post("/users", {
  resolve: async (c) => {
    // 400: Invalid input structure
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    // 401: Not authenticated
    if (!c.locals.user) {
      return Response.json({ error: "Authentication required" }, { status: 401 });
    }
    
    // 403: Authenticated but not authorized
    if (c.locals.user.role !== "admin") {
      return Response.json({ error: "Admin access required" }, { status: 403 });
    }
    
    // 409: Duplicate resource
    const existing = await db.users.findByEmail(c.input.body.email);
    if (existing) {
      return Response.json({ error: "Email already exists" }, { status: 409 });
    }
    
    // 201: Successfully created
    const user = await db.users.create(c.input.body);
    return Response.json(user, { status: 201 });
  }
})

Each status code tells a different story. Choose the one that best describes what happened.

Bodies as Explanations

The response body explains what went wrong and what to do about it.

Minimal:

return Response.json(
  { error: "Not found" },
  { status: 404 }
);

Detailed:

return Response.json(
  {
    error: "User not found",
    userId: id,
    message: "No user exists with the specified ID"
  },
  { status: 404 }
);

With suggestions:

return Response.json(
  {
    error: "Invalid email format",
    field: "email",
    received: "not-an-email",
    hint: "Email must be in format: [email protected]"
  },
  { status: 400 }
);

With error codes:

return Response.json(
  {
    error: "Insufficient funds",
    code: "INSUFFICIENT_FUNDS",
    available: 100.50,
    requested: 200.00,
    hint: "Deposit more funds or reduce the transfer amount"
  },
  { status: 422 }
);

With links:

return Response.json(
  {
    error: "Rate limit exceeded",
    code: "RATE_LIMIT_EXCEEDED",
    limit: 100,
    remaining: 0,
    resetAt: "2024-01-01T00:00:00Z",
    docs: "https://example.com/docs/rate-limits"
  },
  { status: 429 }
);

Consistent error format

Choose a format and stick to it across your API:

Simple format:

interface ErrorResponse {
  error: string;
  details?: unknown;
}

// Usage
return Response.json(
  { error: "User not found", details: { userId: id } },
  { status: 404 }
);

Detailed format:

interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: Record<string, unknown>;
    hint?: string;
  };
}

// Usage
return Response.json(
  {
    error: {
      code: "USER_NOT_FOUND",
      message: "No user exists with the specified ID",
      details: { userId: id },
      hint: "Check that the user ID is correct"
    }
  },
  { status: 404 }
);

JSON:API format (if you’re following that spec):

return Response.json(
  {
    errors: [
      {
        status: "404",
        code: "USER_NOT_FOUND",
        title: "User not found",
        detail: "No user exists with the specified ID",
        meta: { userId: id }
      }
    ]
  },
  { status: 404 }
);

Problem Details (RFC 7807):

return new Response(
  JSON.stringify({
    type: "https://example.com/problems/user-not-found",
    title: "User Not Found",
    status: 404,
    detail: "No user exists with the specified ID",
    instance: `/users/${id}`,
    userId: id
  }),
  {
    status: 404,
    headers: { "Content-Type": "application/problem+json" }
  }
);

Hectoday HTTP doesn’t enforce a format. Choose what works for your API and use it consistently.

Helper functions

Reduce repetition with error helpers:

// helpers/errors.ts
export function badRequest(message: string, details?: unknown) {
  return Response.json(
    { error: message, details },
    { status: 400 }
  );
}

export function unauthorized(message: string = "Unauthorized") {
  return Response.json(
    { error: message },
    { status: 401 }
  );
}

export function forbidden(message: string = "Forbidden") {
  return Response.json(
    { error: message },
    { status: 403 }
  );
}

export function notFound(message: string, details?: unknown) {
  return Response.json(
    { error: message, details },
    { status: 404 }
  );
}

export function conflict(message: string, details?: unknown) {
  return Response.json(
    { error: message, details },
    { status: 409 }
  );
}

// Usage
route.get("/users/:id", {
  resolve: async (c) => {
    const id = c.raw.params.id;
    
    if (!id) {
      return badRequest("Missing user ID");
    }
    
    const user = await db.users.get(id);
    
    if (!user) {
      return notFound("User not found", { userId: id });
    }
    
    return Response.json(user);
  }
})

These are just helpers, not framework magic. They’re pure functions that return Responses.

The pattern

Something fails

Determine the type of failure

Choose appropriate status code

Construct helpful response body

Return Response explicitly

No throwing. No exception mapping. Just explicit returns.

Every error response is visible in the handler. You control exactly what clients see.


Next: Composition over configuration - building larger APIs from small pieces.