Files
ServerKit/backend/app/exceptions.py
T
Juan Denis 97a96d963b Give common API errors stable codes and service wording
Toasts showed backend prose verbatim, with no code to translate
against and "application" where the UI says "service". Not-found,
missing-field, permission, conflict and agent-offline errors now come
from factories in app/exceptions.py that pair one fixed message with
a dotted code (not_found.<resource>, validation.required,
permission.denied, conflict.exists, agent.offline), so the client can
translate them.

The shared app-loading helpers and the apps, domains, servers,
databases and backups routes raise those errors instead of hand
shaping bodies, which drops the hand-shaped body count from 1132 to
850. Five routes picked an HTTP status by comparing error text; they
now key off a result code or a sentinel so rewording can't change a
status. Five broad excepts that echoed str(exc) now log the crash and
return the request id instead.
2026-10-04 18:59:25 -04:00

173 lines
5.8 KiB
Python

"""Typed application errors rendered consistently at the HTTP boundary.
Services may raise these errors when a caller can act on the failure. Flask
owns their JSON representation; service code therefore does not need to know
about ``jsonify`` or duplicate status-code mapping.
The subclasses of built-in exception types are intentional compatibility
bridges. Existing callers that catch ``ValueError`` or ``LookupError`` keep
working while a feature is migrated incrementally to the typed contract.
"""
from collections.abc import Mapping
class ApplicationError(Exception):
"""Base class for expected, client-safe application failures."""
status_code = 500
code = 'application_error'
default_message = 'Application error'
def __init__(self, message=None, *, code=None, details=None):
self.message = str(message or self.default_message)
self.code = code or type(self).code
self.details = dict(details) if isinstance(details, Mapping) else None
super().__init__(self.message)
def to_dict(self, *, request_id=None):
"""Return the stable public error body used by API handlers."""
payload = {
'error': self.message,
'status': self.status_code,
'code': self.code,
}
if self.details:
payload['details'] = self.details
if request_id:
payload['request_id'] = request_id
return payload
class ValidationError(ApplicationError, ValueError):
status_code = 400
code = 'validation_error'
default_message = 'Invalid request'
class AuthenticationError(ApplicationError):
status_code = 401
code = 'authentication_required'
default_message = 'Authentication required'
class PermissionDeniedError(ApplicationError, PermissionError):
status_code = 403
code = 'permission_denied'
default_message = 'Access denied'
class NotFoundError(ApplicationError, LookupError):
status_code = 404
code = 'not_found'
default_message = 'Resource not found'
class ConflictError(ApplicationError):
status_code = 409
code = 'conflict'
default_message = 'Resource conflict'
class DependencyUnavailableError(ApplicationError):
status_code = 503
code = 'dependency_unavailable'
default_message = 'Dependency unavailable'
class AgentOfflineError(DependencyUnavailableError):
"""The target server's agent is not connected, so nothing can reach it."""
code = 'agent.offline'
default_message = (
"The server's agent is offline. Start the agent on that server, "
'then try again.'
)
# ---------------------------------------------------------------------------
# Stable codes the client translates (plan 88 §C).
#
# The frontend (``frontend/src/services/api/errorCodes.js``) swaps the server's
# English for its own translation when it knows the ``code``. A translated code
# must therefore mean exactly ONE message — a code whose text varied per call
# would lose its specifics on the client. So the dotted codes below come only
# from these factories, each with a fixed message (parameterised by ``details``
# at most). The class defaults (``not_found``, ``validation_error``,
# ``permission_denied``, ``conflict``) stay for raises that carry their own
# prose; the client shows that prose untranslated, as before.
#
# code message details
# not_found.<resource> "<Resource> not found" resource
# validation.required "<field> is required" field
# permission.denied PERMISSION_DENIED_MESSAGE -
# conflict.exists "<Resource> already exists" resource
# agent.offline AgentOfflineError's message -
#
# Resource words are the UI's nouns (plan 88 glossary), not model names: an
# ``Application`` row is a "service" to the person reading the toast.
# ---------------------------------------------------------------------------
RESOURCE_LABELS = {
'service': 'Service',
'server': 'Server',
'domain': 'Domain',
'database': 'Database',
'backup': 'Backup',
'snapshot': 'Snapshot',
'config_checkpoint': 'Config checkpoint',
'deployment': 'Deployment',
'template': 'Template',
'user': 'User',
}
PERMISSION_DENIED_MESSAGE = "You don't have permission to do this. Ask an admin for access."
def _resource_label(resource):
try:
return RESOURCE_LABELS[resource]
except KeyError: # a typo here would ship a code nobody translates
raise ValueError(f'unknown resource kind {resource!r}; add it to RESOURCE_LABELS') from None
def not_found(resource):
"""``NotFoundError`` for one resource kind: ``raise not_found('service')``."""
return NotFoundError(
f'{_resource_label(resource)} not found',
code=f'not_found.{resource}',
details={'resource': resource},
)
def field_required(field):
"""``ValidationError`` for a missing request field: ``raise field_required('name')``."""
return ValidationError(
f'{field} is required',
code='validation.required',
details={'field': field},
)
def permission_denied():
"""``PermissionDeniedError`` for a caller who may not act on this resource.
The message is deliberately generic: it reveals nothing the status code
doesn't, and tells the caller what they can do about it.
"""
return PermissionDeniedError(PERMISSION_DENIED_MESSAGE, code='permission.denied')
def already_exists(resource):
"""``ConflictError`` for a create that collides with an existing row."""
return ConflictError(
f'{_resource_label(resource)} already exists',
code='conflict.exists',
details={'resource': resource},
)
def agent_offline():
"""``AgentOfflineError`` (503) for a server whose agent is not connected."""
return AgentOfflineError()