From 0aab404e35d4bc2f86ed45887fff79c379195f98 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 3 Nov 2026 00:31:21 -0500 Subject: [PATCH] refactor(agents): raise typed errors from the report endpoint Keeps the hand-shaped error-body ratchet at its ceiling. Co-Authored-By: Claude Sonnet 5.5 --- backend/app/api/agent_report.py | 15 +++++++++------ backend/app/exceptions.py | 6 ++++++ docs/MIGRATION_INVENTORY.md | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/backend/app/api/agent_report.py b/backend/app/api/agent_report.py index 870c74cd..958c2321 100644 --- a/backend/app/api/agent_report.py +++ b/backend/app/api/agent_report.py @@ -13,6 +13,9 @@ Auth (no JWT/CSRF; the HMAC is the credential): from flask import Blueprint, jsonify, request from app import limiter +from app.exceptions import ( + AuthenticationError, PayloadTooLargeError, PermissionDeniedError, ValidationError, +) from app.services import agent_health_service, permission_levels from app.utils.ip_utils import is_ip_allowed @@ -31,23 +34,23 @@ def _rate_key(): @limiter.limit("30 per minute", key_func=_rate_key) def report(): if (request.content_length or 0) > MAX_BODY_BYTES: - return jsonify({'error': 'payload too large'}), 413 + raise PayloadTooLargeError() raw = request.get_data(cache=True) if len(raw) > MAX_BODY_BYTES: - return jsonify({'error': 'payload too large'}), 413 + raise PayloadTooLargeError() server = agent_health_service.authenticate_report(raw, request.headers, MAX_SKEW_SECONDS) if not server: - return jsonify({'error': 'authentication failed'}), 401 + raise AuthenticationError('authentication failed') if server.allowed_ips and not is_ip_allowed(request.remote_addr, server.allowed_ips): - return jsonify({'error': 'IP not allowed'}), 403 + raise PermissionDeniedError('IP not allowed') body = request.get_json(silent=True) if not isinstance(body, dict): - return jsonify({'error': 'JSON object required'}), 400 + raise ValidationError('JSON object required') level = body.get('permission_level') if level not in permission_levels.LEVELS: - return jsonify({'error': 'permission_level must be monitor, protect or full'}), 400 + raise ValidationError('permission_level must be monitor, protect or full') interval = agent_health_service.ingest_report(server, level, body) return jsonify({'ok': True, 'interval_seconds': interval}) diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py index 52f896b4..d0dcb47f 100644 --- a/backend/app/exceptions.py +++ b/backend/app/exceptions.py @@ -57,6 +57,12 @@ class PermissionDeniedError(ApplicationError, PermissionError): default_message = 'Access denied' +class PayloadTooLargeError(ApplicationError): + status_code = 413 + code = 'payload_too_large' + default_message = 'Payload too large' + + class NotFoundError(ApplicationError, LookupError): status_code = 404 code = 'not_found' diff --git a/docs/MIGRATION_INVENTORY.md b/docs/MIGRATION_INVENTORY.md index f8323690..a906a013 100644 --- a/docs/MIGRATION_INVENTORY.md +++ b/docs/MIGRATION_INVENTORY.md @@ -16,12 +16,12 @@ debt. Regenerate with `python scripts/generate-migration-inventory.py`. | Routes on bare @jwt_required() | auth_required() / role decorators | 598 | 598 | REGISTERED EXCEPTION: JWT-only is the deliberate default; conversion grants API-key access and happens per route, on decision | | HTTP statuses chosen by sniffing error text | typed errors from app.exceptions | 0 | 0 | INVARIANT at 0 - migration completed 2026-08-19 | | API crashes swallowed without recording | app.error_reporting | 0 | 0 | INVARIANT at 0 | -| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 855 | 850 | migrate when touched; new endpoints raise | +| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 849 | 850 | migrate when touched; new endpoints raise | | Raw subprocess calls outside the runners | app/utils/system.py runners | 23 | 23 | migrate when touched | | Controller-boundary violations (routes doing service work) | service layer extraction | 453 | 453 | migrate when touched (first-wave ratchet) | | raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 374 | 398 | migrate when touched | | per-page toast.error extractions in pages/ | E1: query-layer error presentation | 191 | 206 | migrate when touched | -| hand-rolled form-group blocks | F2: FormField/useForm | 314 | 321 | migrate when touched | +| hand-rolled form-group blocks | F2: FormField/useForm | 315 | 321 | migrate when touched | | unencoded ?k=${v} query interpolations in services/api | C4: buildQuery/encoding template | 0 | 0 | INVARIANT at 0 | | raw setInterval pollers | E2: usePolling/refetchInterval | 7 | 7 | DELIBERATE RESIDUE: clock ticks, socket-fallback hooks, and sibling-repo extension timers - each listed per file | | direct navigator.clipboard call sites | F3: copyToClipboard | 0 | 0 | INVARIANT at 0 |