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 <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Sonnet 5.5 committed 2026-11-03 00:31:21 -05:00
1 parent a0662254ae
commit 0aab404e35
3 files changed
+17 -8

No files matched your search

+9 -6
View File
@@ -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})
+6
View File
@@ -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'
+2 -2
View File
@@ -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 |