Skip to content

Errors

Every non-2xx response raises an APIError subclass mapped from the HTTP status code, parsed from the Campus error envelope (with a legacy error_code fallback). Auth endpoints normalize to the same hierarchy, and the OAuth protocol error survives even when the server strips envelope details in production — it is recovered from the AUTH_* error code and readable via .oauth_error.

from campus_python import errors

try:
    campus.api.circles.new(name="dup", tag="project")
except errors.ValidationError as err:              # 422
    for field_err in err.get_errors_for_field("name"):
        print(field_err.code, field_err.message)   # e.g. CONFLICT / name collision
except errors.ConflictError as err:                # 409
    print(err.request_id)
except errors.AuthenticationError as err:          # 401
    if err.oauth_error == "invalid_grant":
        ...                                        # session gone — re-login

Common attributes on every error: status_code, error (machine code), error_description, request_id (from the envelope, for tracing), details, and notes (client-side headers/body for debugging).

Naming note: the 501 class is this package's errors.NotImplementedError — when catching broadly, qualify it (errors.NotImplementedError) so it doesn't shadow the builtin.

Status Class Typical cause
400 BadRequestError malformed request
401 AuthenticationError missing/expired credentials, OAuth errors (oauth_error)
403 AccessDeniedError insufficient permission; broker missing_scopes in details
404 NotFoundError unknown id/slug/vault key
409 ConflictError name collisions, duplicate resources
422 ValidationError validation failure — inspect .field_errors
429 RateLimitExceededError rate limited (traces ingest trips the client breaker first)
500 ServerError server-side failure (device-flow polls retry these)
501 errors.NotImplementedError endpoint stubbed upstream
502 MalformedResponseError upstream returned garbage
503 UnavailableError service down

campus_python.errors

campus.python.errors

Common error types used across Campus API for Python.

Reference: - campus/api/docs/api-error-spec.md - campus/auth/docs/auth-error-spec.md

FieldError dataclass

A single field validation error.

Attributes:

Name Type Description
field str

The field name that failed validation

code str

Machine-readable error code (e.g., "INVALID_FORMAT", "MISSING")

message str

Human-readable error message

Source code in campus_python/errors.py
14
15
16
17
18
19
20
21
22
23
24
25
@dataclass
class FieldError:
    """A single field validation error.

    Attributes:
        field: The field name that failed validation
        code: Machine-readable error code (e.g., "INVALID_FORMAT", "MISSING")
        message: Human-readable error message
    """
    field: str
    code: str
    message: str

field: str instance-attribute

code: str instance-attribute

message: str instance-attribute

__init__(field: str, code: str, message: str) -> None

APIError

Bases: Exception

Base exception for all campus client errors.

Attributes:

Name Type Description
status_code int

HTTP status code

error str

Error code (e.g., "NOT_FOUND", "VALIDATION_FAILED")

error_description str | None

Human-readable error message

error_uri str | None

Optional URI to error documentation

request_id str | None

Request identifier for tracing (from error envelope)

details dict[str, Any]

Additional error details from server

errors list[FieldError] | None

Field-level validation errors (for ValidationError)

notes dict[str, Any] | None

Client-side metadata (headers, body)

Source code in campus_python/errors.py
 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
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
class APIError(Exception):
    """Base exception for all campus client errors.

    Attributes:
        status_code: HTTP status code
        error: Error code (e.g., "NOT_FOUND", "VALIDATION_FAILED")
        error_description: Human-readable error message
        error_uri: Optional URI to error documentation
        request_id: Request identifier for tracing (from error envelope)
        details: Additional error details from server
        errors: Field-level validation errors (for ValidationError)
        notes: Client-side metadata (headers, body)
    """
    _registered_errors: dict[int, type["APIError"]] = {}
    status_code: int
    error: str
    error_description: str | None
    error_uri: str | None
    request_id: str | None
    details: dict[str, Any]
    errors: list[FieldError] | None
    notes: dict[str, Any] | None

    def __init__(
            self,
            status_code: int | None = None,
            error_description: str | None = None,
            *,
            error_uri: str | None = None,
            request_id: str | None = None,
            details: dict[str, Any] | None = None,
            errors: list[FieldError] | None = None,
            notes: dict[str, Any] | None = None
    ):
        if status_code is not None:
            self.status_code = status_code
        if error_description:
            super().__init__(error_description)
        else:
            super().__init__()
        self.error_description = error_description
        self.error_uri = error_uri
        self.request_id = request_id
        self.details = details or {}
        self.errors = errors
        self.notes = notes

    @property
    def oauth_error(self) -> str | None:
        """Get the OAuth protocol error string from details.

        Returns the OAuth error string (e.g., "invalid_client", "invalid_grant")
        if present in the error details. Used for auth endpoint errors.

        Reference: campus/auth/docs/auth-error-spec.md
        """
        return self.details.get("oauth_error") if self.details else None

    @property
    def oauth_error_description(self) -> str | None:
        """Get the OAuth error description from details.

        Returns the OAuth-specific error description if present.
        """
        return self.details.get("oauth_error_description") if self.details else None

    def __init_subclass__(cls) -> None:
        """Automatically register subclasses in the _registered_errors dict.

        This enables mapping from HTTP status codes to specific error classes for automatic error handling based on status code.
        """
        cls._registered_errors[cls.status_code] = cls

    @classmethod
    def with_status_code(
            cls: type["APIError"],
            status_code: int,
            response_data: dict[str, Any] | str | None = None,
            *,
            error: str | None = None,
            error_description: str | None = None,
            error_uri: str | None = None,
            request_id: str | None = None,
            details: dict[str, Any] | None = None,
            errors: list[FieldError] | None = None,
    ) -> "APIError | None":
        """Create an APIError instance based on status code.

        Handles both the new error envelope format and legacy format.

        Args:
            status_code: HTTP status code
            response_data: Full response data (dict or string) for parsing
            error: Explicit error code (overrides parsed value)
            error_description: Explicit error description (overrides parsed value)
            error_uri: Optional URI to error documentation
            request_id: Request identifier for tracing
            details: Additional error details
            errors: Field-level validation errors

        Returns:
            An APIError subclass instance or None if status_code < 400
        """
        if status_code < 400:
            return None

        # Determine error code and description from response data if not explicitly provided
        if isinstance(response_data, dict):
            # Try new error envelope format first
            if "error" in response_data and isinstance(response_data["error"], dict):
                error_obj = response_data["error"]
                error = error or error_obj.get("code")
                error_description = error_description or error_obj.get("message")
                request_id = request_id or error_obj.get("request_id")
                details = details or error_obj.get("details")
                if not details:
                    # Production strips envelope details, taking the
                    # OAuth error code with it; recover it from the
                    # AUTH_* code so auth callers can still branch on
                    # APIError.oauth_error (#87).
                    derived = oauth_error_from_code(error_obj.get("code", ""))
                    if derived:
                        details = {"oauth_error": derived}

                # Parse field-level errors for validation errors
                if "errors" in error_obj and isinstance(error_obj["errors"], list):
                    errors = errors or [
                        FieldError(
                            field=e.get("field", ""),
                            code=e.get("code", ""),
                            message=e.get("message", "")
                        )
                        for e in error_obj["errors"]
                        if isinstance(e, dict)
                    ]
            # Fallback to legacy format (error_code at root level)
            elif "error_code" in response_data:
                error = error or response_data.get("error_code")
                error_description = error_description or response_data.get("message")
                details = details or response_data.get("details")

        # If response_data is a string, use it as error_description
        elif isinstance(response_data, str):
            error_description = error_description or response_data

        # Get the registered error class for this status code
        error_cls = cls._registered_errors.get(status_code, APIError)

        # For 422, use ValidationError if available
        if status_code == 422:
            error_cls = cls._registered_errors.get(422, ValidationError)

        # Create the error instance
        # All subclasses inherit from APIError and should accept its parameters
        # but if they override __init__ differently, we handle that
        try:
            instance = error_cls(
                status_code=status_code,
                error_description=error_description,
                error_uri=error_uri,
                request_id=request_id,
                details=details,
                errors=errors,
                notes=None
            )
        except TypeError:
            # Subclass has a different __init__ signature
            # Create with basic signature and set attributes manually
            instance = error_cls(error_description=error_description)
            # Manually set the new attributes if the subclass doesn't support them
            if not hasattr(instance, 'request_id'):
                instance.request_id = request_id
            if not hasattr(instance, 'details'):
                instance.details = details or {}
            if not hasattr(instance, 'errors'):
                instance.errors = errors
            if not hasattr(instance, 'notes'):
                instance.notes = None

        # Override the class-level error attribute if one was parsed
        if error is not None and hasattr(instance, 'error'):
            # Only override if the parsed value differs from the class default
            class_error = getattr(error_cls, 'error', None)
            if error != class_error:
                instance.error = error

        return instance

status_code: int instance-attribute

error: str instance-attribute

error_description: str | None = error_description instance-attribute

error_uri: str | None = error_uri instance-attribute

request_id: str | None = request_id instance-attribute

details: dict[str, Any] = details or {} instance-attribute

errors: list[FieldError] | None = errors instance-attribute

notes: dict[str, Any] | None = notes instance-attribute

oauth_error: str | None property

Get the OAuth protocol error string from details.

Returns the OAuth error string (e.g., "invalid_client", "invalid_grant") if present in the error details. Used for auth endpoint errors.

Reference: campus/auth/docs/auth-error-spec.md

oauth_error_description: str | None property

Get the OAuth error description from details.

Returns the OAuth-specific error description if present.

__init__(status_code: int | None = None, error_description: str | None = None, *, error_uri: str | None = None, request_id: str | None = None, details: dict[str, Any] | None = None, errors: list[FieldError] | None = None, notes: dict[str, Any] | None = None)

Source code in campus_python/errors.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def __init__(
        self,
        status_code: int | None = None,
        error_description: str | None = None,
        *,
        error_uri: str | None = None,
        request_id: str | None = None,
        details: dict[str, Any] | None = None,
        errors: list[FieldError] | None = None,
        notes: dict[str, Any] | None = None
):
    if status_code is not None:
        self.status_code = status_code
    if error_description:
        super().__init__(error_description)
    else:
        super().__init__()
    self.error_description = error_description
    self.error_uri = error_uri
    self.request_id = request_id
    self.details = details or {}
    self.errors = errors
    self.notes = notes

__init_subclass__() -> None

Automatically register subclasses in the _registered_errors dict.

This enables mapping from HTTP status codes to specific error classes for automatic error handling based on status code.

Source code in campus_python/errors.py
121
122
123
124
125
126
def __init_subclass__(cls) -> None:
    """Automatically register subclasses in the _registered_errors dict.

    This enables mapping from HTTP status codes to specific error classes for automatic error handling based on status code.
    """
    cls._registered_errors[cls.status_code] = cls

with_status_code(status_code: int, response_data: dict[str, Any] | str | None = None, *, error: str | None = None, error_description: str | None = None, error_uri: str | None = None, request_id: str | None = None, details: dict[str, Any] | None = None, errors: list[FieldError] | None = None) -> APIError | None classmethod

Create an APIError instance based on status code.

Handles both the new error envelope format and legacy format.

Parameters:

Name Type Description Default
status_code int

HTTP status code

required
response_data dict[str, Any] | str | None

Full response data (dict or string) for parsing

None
error str | None

Explicit error code (overrides parsed value)

None
error_description str | None

Explicit error description (overrides parsed value)

None
error_uri str | None

Optional URI to error documentation

None
request_id str | None

Request identifier for tracing

None
details dict[str, Any] | None

Additional error details

None
errors list[FieldError] | None

Field-level validation errors

None

Returns:

Type Description
APIError | None

An APIError subclass instance or None if status_code < 400

Source code in campus_python/errors.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
@classmethod
def with_status_code(
        cls: type["APIError"],
        status_code: int,
        response_data: dict[str, Any] | str | None = None,
        *,
        error: str | None = None,
        error_description: str | None = None,
        error_uri: str | None = None,
        request_id: str | None = None,
        details: dict[str, Any] | None = None,
        errors: list[FieldError] | None = None,
) -> "APIError | None":
    """Create an APIError instance based on status code.

    Handles both the new error envelope format and legacy format.

    Args:
        status_code: HTTP status code
        response_data: Full response data (dict or string) for parsing
        error: Explicit error code (overrides parsed value)
        error_description: Explicit error description (overrides parsed value)
        error_uri: Optional URI to error documentation
        request_id: Request identifier for tracing
        details: Additional error details
        errors: Field-level validation errors

    Returns:
        An APIError subclass instance or None if status_code < 400
    """
    if status_code < 400:
        return None

    # Determine error code and description from response data if not explicitly provided
    if isinstance(response_data, dict):
        # Try new error envelope format first
        if "error" in response_data and isinstance(response_data["error"], dict):
            error_obj = response_data["error"]
            error = error or error_obj.get("code")
            error_description = error_description or error_obj.get("message")
            request_id = request_id or error_obj.get("request_id")
            details = details or error_obj.get("details")
            if not details:
                # Production strips envelope details, taking the
                # OAuth error code with it; recover it from the
                # AUTH_* code so auth callers can still branch on
                # APIError.oauth_error (#87).
                derived = oauth_error_from_code(error_obj.get("code", ""))
                if derived:
                    details = {"oauth_error": derived}

            # Parse field-level errors for validation errors
            if "errors" in error_obj and isinstance(error_obj["errors"], list):
                errors = errors or [
                    FieldError(
                        field=e.get("field", ""),
                        code=e.get("code", ""),
                        message=e.get("message", "")
                    )
                    for e in error_obj["errors"]
                    if isinstance(e, dict)
                ]
        # Fallback to legacy format (error_code at root level)
        elif "error_code" in response_data:
            error = error or response_data.get("error_code")
            error_description = error_description or response_data.get("message")
            details = details or response_data.get("details")

    # If response_data is a string, use it as error_description
    elif isinstance(response_data, str):
        error_description = error_description or response_data

    # Get the registered error class for this status code
    error_cls = cls._registered_errors.get(status_code, APIError)

    # For 422, use ValidationError if available
    if status_code == 422:
        error_cls = cls._registered_errors.get(422, ValidationError)

    # Create the error instance
    # All subclasses inherit from APIError and should accept its parameters
    # but if they override __init__ differently, we handle that
    try:
        instance = error_cls(
            status_code=status_code,
            error_description=error_description,
            error_uri=error_uri,
            request_id=request_id,
            details=details,
            errors=errors,
            notes=None
        )
    except TypeError:
        # Subclass has a different __init__ signature
        # Create with basic signature and set attributes manually
        instance = error_cls(error_description=error_description)
        # Manually set the new attributes if the subclass doesn't support them
        if not hasattr(instance, 'request_id'):
            instance.request_id = request_id
        if not hasattr(instance, 'details'):
            instance.details = details or {}
        if not hasattr(instance, 'errors'):
            instance.errors = errors
        if not hasattr(instance, 'notes'):
            instance.notes = None

    # Override the class-level error attribute if one was parsed
    if error is not None and hasattr(instance, 'error'):
        # Only override if the parsed value differs from the class default
        class_error = getattr(error_cls, 'error', None)
        if error != class_error:
            instance.error = error

    return instance

BadRequestError

Bases: APIError

Raised when request validation fails.

Source code in campus_python/errors.py
247
248
249
250
class BadRequestError(APIError):
    """Raised when request validation fails."""
    status_code = 400
    error = "invalid_request"

status_code = 400 class-attribute instance-attribute

error = 'invalid_request' class-attribute instance-attribute

AuthenticationError

Bases: APIError

Raised when client is unauthenticated or authentication fails.

Source code in campus_python/errors.py
253
254
255
256
class AuthenticationError(APIError):
    """Raised when client is unauthenticated or authentication fails."""
    status_code = 401
    error = "authentication_error"

status_code = 401 class-attribute instance-attribute

error = 'authentication_error' class-attribute instance-attribute

AccessDeniedError

Bases: APIError

Raised when an authenticated client lacks required permissions.

Source code in campus_python/errors.py
259
260
261
262
class AccessDeniedError(APIError):
    """Raised when an authenticated client lacks required permissions."""
    status_code = 403
    error = "access_denied"

status_code = 403 class-attribute instance-attribute

error = 'access_denied' class-attribute instance-attribute

NotFoundError

Bases: APIError

Raised when a requested resource is not found.

Source code in campus_python/errors.py
265
266
267
268
class NotFoundError(APIError):
    """Raised when a requested resource is not found."""
    status_code = 404
    error = "not_found"

status_code = 404 class-attribute instance-attribute

error = 'not_found' class-attribute instance-attribute

ConflictError

Bases: APIError

Raised when a conflict occurs.

Source code in campus_python/errors.py
271
272
273
274
class ConflictError(APIError):
    """Raised when a conflict occurs."""
    status_code = 409
    error = "conflict_error"

status_code = 409 class-attribute instance-attribute

error = 'conflict_error' class-attribute instance-attribute

ValidationError

Bases: APIError

Raised when request validation fails with field-level details.

This error is returned with status code 422 and includes structured field-level error information.

Reference: campus/api/docs/api-error-spec.md Section 5

Source code in campus_python/errors.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
class ValidationError(APIError):
    """Raised when request validation fails with field-level details.

    This error is returned with status code 422 and includes structured
    field-level error information.

    Reference: campus/api/docs/api-error-spec.md Section 5
    """
    status_code = 422
    error = "VALIDATION_FAILED"

    @property
    def field_errors(self) -> list[FieldError]:
        """Return list of field errors, empty if none."""
        return self.errors or []

    def get_errors_for_field(self, field_name: str) -> list[FieldError]:
        """Get all errors for a specific field.

        Args:
            field_name: The field name to get errors for

        Returns:
            List of FieldError objects for the specified field
        """
        return [e for e in self.field_errors if e.field == field_name]

status_code = 422 class-attribute instance-attribute

error = 'VALIDATION_FAILED' class-attribute instance-attribute

field_errors: list[FieldError] property

Return list of field errors, empty if none.

get_errors_for_field(field_name: str) -> list[FieldError]

Get all errors for a specific field.

Parameters:

Name Type Description Default
field_name str

The field name to get errors for

required

Returns:

Type Description
list[FieldError]

List of FieldError objects for the specified field

Source code in campus_python/errors.py
293
294
295
296
297
298
299
300
301
302
def get_errors_for_field(self, field_name: str) -> list[FieldError]:
    """Get all errors for a specific field.

    Args:
        field_name: The field name to get errors for

    Returns:
        List of FieldError objects for the specified field
    """
    return [e for e in self.field_errors if e.field == field_name]

RateLimitExceededError

Bases: APIError

Raised when rate limits are exceeded.

Source code in campus_python/errors.py
305
306
307
308
class RateLimitExceededError(APIError):
    """Raised when rate limits are exceeded."""
    status_code = 429
    error = "too_many_requests"

status_code = 429 class-attribute instance-attribute

error = 'too_many_requests' class-attribute instance-attribute

ServerError

Bases: APIError

Raised when the server encounters an error.

Source code in campus_python/errors.py
311
312
313
314
class ServerError(APIError):
    """Raised when the server encounters an error."""
    status_code = 500
    error = "server_error"

status_code = 500 class-attribute instance-attribute

error = 'server_error' class-attribute instance-attribute

NotImplementedError

Bases: APIError

Raised when the endpoint is not yet implemented.

Source code in campus_python/errors.py
317
318
319
320
class NotImplementedError(APIError):
    """Raised when the endpoint is not yet implemented."""
    status_code = 501
    error = "not_implemented"

status_code = 501 class-attribute instance-attribute

error = 'not_implemented' class-attribute instance-attribute

MalformedResponseError

Bases: APIError

Raised when the API response is malformed.

Source code in campus_python/errors.py
323
324
325
326
class MalformedResponseError(APIError):
    """Raised when the API response is malformed."""
    status_code = 502
    error = "malformed_response"

status_code = 502 class-attribute instance-attribute

error = 'malformed_response' class-attribute instance-attribute

UnavailableError

Bases: APIError

Raised when the service is unavailable.

Source code in campus_python/errors.py
329
330
331
332
class UnavailableError(APIError):
    """Raised when the service is unavailable."""
    status_code = 503
    error = "service_unavailable"

status_code = 503 class-attribute instance-attribute

error = 'service_unavailable' class-attribute instance-attribute

oauth_error_from_code(code: str) -> str | None

Recover the OAuth error code from a Campus envelope AUTH_* code.

The auth server strips the error envelope's details in production (campus/common/errors/handlers.py), taking details.oauth_error with it; the code remains and encodes the OAuth error as AUTH_ (upper snake case), with the exceptions mapped in _CAMPUS_CODE_TO_OAUTH_ERROR.

Returns None for codes that carry no OAuth error (plain API codes).

Source code in campus_python/errors.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def oauth_error_from_code(code: str) -> str | None:
    """Recover the OAuth error code from a Campus envelope AUTH_* code.

    The auth server strips the error envelope's details in production
    (campus/common/errors/handlers.py), taking details.oauth_error with
    it; the code remains and encodes the OAuth error as
    AUTH_<OAUTH_ERROR> (upper snake case), with the exceptions mapped
    in _CAMPUS_CODE_TO_OAUTH_ERROR.

    Returns None for codes that carry no OAuth error (plain API codes).
    """
    if not code:
        return None
    if code in _CAMPUS_CODE_TO_OAUTH_ERROR:
        return _CAMPUS_CODE_TO_OAUTH_ERROR[code]
    if code.startswith("AUTH_"):
        return code[len("AUTH_"):].lower()
    return None