Skip to content

feat: implement centralized error handling strategy #7

@rorybyrne

Description

@rorybyrne

Summary

The codebase lacks a centralized error handling strategy. CLAUDE.md specifies domain errors should be raised and mapped to HTTP, but no implementation exists.

Current State

  • No osa/core/error.py or domain error types
  • No HTTP error mapper
  • Exceptions bubble up as 500 Internal Server Errors

Proposed Design

1. Domain Error Hierarchy

# osa/domain/shared/error.py

class DomainError(Exception):
    """Base class for all domain errors."""
    
    def __init__(self, message: str, code: str | None = None):
        self.message = message
        self.code = code or self.__class__.__name__
        super().__init__(message)


class NotFoundError(DomainError):
    """Resource not found."""
    pass


class ValidationError(DomainError):
    """Input validation failed."""
    
    def __init__(self, message: str, field: str | None = None):
        super().__init__(message, code="VALIDATION_ERROR")
        self.field = field


class InvalidStateError(DomainError):
    """Operation not allowed in current state."""
    pass


class ConflictError(DomainError):
    """Resource already exists or version conflict."""
    pass


class AuthorizationError(DomainError):
    """User not authorized for this operation."""
    pass

2. HTTP Error Mapper

# osa/application/api/v1/errors.py

from fastapi import HTTPException
from osa.domain.shared.error import (
    DomainError, NotFoundError, ValidationError,
    InvalidStateError, ConflictError, AuthorizationError
)

ERROR_STATUS_MAP = {
    NotFoundError: 404,
    ValidationError: 422,
    InvalidStateError: 409,
    ConflictError: 409,
    AuthorizationError: 403,
}

def map_domain_error(error: DomainError) -> HTTPException:
    """Map domain errors to HTTP exceptions."""
    status_code = ERROR_STATUS_MAP.get(type(error), 400)
    return HTTPException(
        status_code=status_code,
        detail={
            "code": error.code,
            "message": error.message,
            "field": getattr(error, "field", None),
        }
    )

3. Global Exception Handler

# osa/application/api/rest/app.py

@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
    http_exc = map_domain_error(exc)
    return JSONResponse(
        status_code=http_exc.status_code,
        content=http_exc.detail,
    )

4. Usage in Services

class DepositionService(Service):
    async def submit(self, dep_id: DepositionSRN) -> SubmitResult:
        dep = await self.repo.get(dep_id)
        if dep is None:
            raise NotFoundError(f"Deposition {dep_id} not found")
        if dep.status != DepositionStatus.DRAFT:
            raise InvalidStateError(f"Cannot submit from {dep.status}")
        # ...

Files to Create/Modify

  • Create osa/domain/shared/error.py - Domain error hierarchy
  • Create osa/application/api/v1/errors.py - HTTP error mapper
  • Update osa/application/api/rest/app.py - Register global handler
  • Update services to raise domain errors

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions