Dev Playbook
Conventions

Error Handling

Exception handling patterns and error management strategies.

See also: api-error-format.md for the standard API error response format (RFC 7807).

Core Principle

Handle errors at the right level. Don't catch exceptions you can't meaningfully handle. Let them bubble up to a global handler.

Error Categories

CategoryExampleResponse CodeHandling
ValidationInvalid email, missing field400Return specific field errors
AuthenticationMissing/expired token401Return generic "unauthorized"
AuthorizationWrong role, wrong tenant403Return generic "forbidden"
Not FoundResource doesn't exist404Return "not found"
Business RuleCourse full, duplicate title409 / 422Return specific business error
External ServiceAPI timeout, service down502 / 503Retry or degrade gracefully
InternalUnhandled exception, bug500Log full details, return generic error

Architecture

Controller / Route Handler
  ↓ catches validation errors → 400

Service / Use Case
  ↓ throws business exceptions → 409/422

Repository / External Call
  ↓ throws infrastructure exceptions → 502/503

Global Exception Handler
  ↓ catches everything else → 500 (logged, generic response)

Implementation

.NET — Exception Middleware

// Custom exception types
public class NotFoundException : Exception
{
    public NotFoundException(string entity, object id)
        : base($"{entity} with id '{id}' was not found.") { }
}

public class BusinessRuleException : Exception
{
    public BusinessRuleException(string message) : base(message) { }
}

public class ConflictException : Exception
{
    public ConflictException(string message) : base(message) { }
}

// Global exception handler middleware
public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (NotFoundException ex)
        {
            context.Response.StatusCode = 404;
            await WriteProblemDetails(context, "Not Found", ex.Message, 404);
        }
        catch (BusinessRuleException ex)
        {
            context.Response.StatusCode = 422;
            await WriteProblemDetails(context, "Business Rule Violation", ex.Message, 422);
        }
        catch (ConflictException ex)
        {
            context.Response.StatusCode = 409;
            await WriteProblemDetails(context, "Conflict", ex.Message, 409);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception for {RequestPath}", context.Request.Path);
            context.Response.StatusCode = 500;
            await WriteProblemDetails(context, "Internal Server Error",
                "An unexpected error occurred.", 500);
        }
    }
}

TypeScript — Error Classes

// Custom error classes
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
  ) {
    super(message);
  }
}

class NotFoundError extends AppError {
  constructor(entity: string, id: string) {
    super(404, 'NOT_FOUND', `${entity} with id '${id}' not found`);
  }
}

class BusinessRuleError extends AppError {
  constructor(message: string) {
    super(422, 'BUSINESS_RULE_VIOLATION', message);
  }
}

class ConflictError extends AppError {
  constructor(message: string) {
    super(409, 'CONFLICT', message);
  }
}

// Global error handler (Express / Next.js API route wrapper)
function withErrorHandler(handler: Function) {
  return async (req: Request, res: Response) => {
    try {
      await handler(req, res);
    } catch (error) {
      if (error instanceof AppError) {
        res.status(error.statusCode).json({
          type: `https://docs.api.com/errors/${error.code.toLowerCase()}`,
          title: error.code,
          status: error.statusCode,
          detail: error.message,
        });
      } else {
        logger.error('Unhandled error', { error, path: req.url });
        res.status(500).json({
          type: 'https://docs.api.com/errors/internal',
          title: 'Internal Server Error',
          status: 500,
          detail: 'An unexpected error occurred.',
        });
      }
    }
  };
}

Python — Exception Handlers

# Custom exceptions
class AppException(Exception):
    def __init__(self, status_code: int, code: str, detail: str):
        self.status_code = status_code
        self.code = code
        self.detail = detail

class NotFoundException(AppException):
    def __init__(self, entity: str, id: str):
        super().__init__(404, "NOT_FOUND", f"{entity} with id '{id}' not found")

class BusinessRuleException(AppException):
    def __init__(self, detail: str):
        super().__init__(422, "BUSINESS_RULE_VIOLATION", detail)

# FastAPI exception handler
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "type": f"https://docs.api.com/errors/{exc.code.lower()}",
            "title": exc.code,
            "status": exc.status_code,
            "detail": exc.detail,
        },
    )

External Service Errors

Retry Strategy

Retry 1 → wait 1s
Retry 2 → wait 2s
Retry 3 → wait 4s
Give up → return 503 or fallback
// .NET — Polly retry policy
builder.Services.AddHttpClient("ExternalApi")
    .AddTransientHttpErrorPolicy(policy =>
        policy.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))));

Circuit Breaker

When an external service is consistently failing, stop calling it temporarily:

Closed (normal) → Too many failures → Open (reject calls for 30s) → Half-Open (try one) → Closed

Graceful Degradation

  • If the recommendation service is down, show default content instead of an error
  • If the email service is down, queue the email for later delivery
  • If the search service is down, fall back to database query

Rules

  1. Never swallow exceptions silently — catch only if you handle it meaningfully
  2. Never expose internal details in API responses — stack traces, SQL queries, file paths
  3. Log at the boundary — global handler logs unhandled exceptions, not every catch block
  4. Use typed exceptionsNotFoundException, not throw new Exception("not found")
  5. Fail fast on startup — missing config, unreachable database = crash, don't limp along
  6. Return consistent error format — always RFC 7807 Problem Details (see api-error-format.md)
  7. Don't use exceptions for flow control — check if (course == null) instead of catching NullReferenceException

Anti-Patterns

// DON'T: Pokemon exception handling (catch 'em all)
try { ... }
catch (Exception) { return null; }  // What went wrong? Nobody knows.

// DON'T: Log and throw (duplicate logging)
catch (Exception ex)
{
    _logger.LogError(ex, "Failed");
    throw;  // Global handler will log AGAIN
}

// DON'T: Expose internals
catch (NpgsqlException ex)
{
    return BadRequest(ex.Message);  // Leaks DB schema info
}

// DON'T: Empty catch
catch (Exception) { }  // Silently swallowed. Debugging nightmare.

// DO: Handle specifically or let it bubble
catch (DuplicateKeyException)
{
    throw new ConflictException("A course with this title already exists");
}

On this page