File size: 2,600 Bytes
6741fc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
"""Typed API errors.

Spec §7: *"API returns typed error responses, never stack traces."* Every failure
that reaches a client is one of these, serialised to a stable JSON envelope:

    {"error": {"code": "...", "message": "...", "details": {...}}}
"""

from __future__ import annotations

from enum import StrEnum
from typing import Any


class ErrorCode(StrEnum):
    """Stable, machine-readable error identifiers. Values are part of the API."""

    UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
    FILE_TOO_LARGE = "file_too_large"
    EMPTY_FILE = "empty_file"
    MALFORMED_FILE = "malformed_file"
    FORBIDDEN_ORIGIN = "forbidden_origin"
    NOT_FOUND = "not_found"
    INVALID_STATE_TRANSITION = "invalid_state_transition"
    VALIDATION_ERROR = "validation_error"
    RATE_LIMITED = "rate_limited"
    UPSTREAM_UNAVAILABLE = "upstream_unavailable"
    EXTRACTION_FAILED = "extraction_failed"
    INTERNAL_ERROR = "internal_error"


class LedgerLensError(Exception):
    """Base class for every error the API deliberately surfaces."""

    status_code: int = 500
    code: ErrorCode = ErrorCode.INTERNAL_ERROR

    def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
        super().__init__(message)
        self.message = message
        self.details = details or {}

    def to_payload(self) -> dict[str, Any]:
        return {"error": {"code": str(self.code), "message": self.message, "details": self.details}}


class UnsupportedMediaTypeError(LedgerLensError):
    status_code = 415
    code = ErrorCode.UNSUPPORTED_MEDIA_TYPE


class FileTooLargeError(LedgerLensError):
    status_code = 413
    code = ErrorCode.FILE_TOO_LARGE


class EmptyFileError(LedgerLensError):
    status_code = 400
    code = ErrorCode.EMPTY_FILE


class MalformedFileError(LedgerLensError):
    status_code = 400
    code = ErrorCode.MALFORMED_FILE


class NotFoundError(LedgerLensError):
    status_code = 404
    code = ErrorCode.NOT_FOUND


class InvalidStateTransitionError(LedgerLensError):
    status_code = 409
    code = ErrorCode.INVALID_STATE_TRANSITION


class ValidationFailedError(LedgerLensError):
    status_code = 422
    code = ErrorCode.VALIDATION_ERROR


class UpstreamUnavailableError(LedgerLensError):
    """A dependency (Claude, database) failed after every retry was exhausted."""

    status_code = 503
    code = ErrorCode.UPSTREAM_UNAVAILABLE


class ExtractionFailedError(LedgerLensError):
    """The model could not produce a schema-valid extraction within the retry budget."""

    status_code = 422
    code = ErrorCode.EXTRACTION_FAILED