Files
roboco/roboco/api/middleware.py
T
98e618c243 [aaac85d2] Rate limit guardrails for Anthropic and Ollama providers (#104)
* [25aa5b24] Implement rate-limit Zustand store, Axios interceptor, WebSocket hook, banner component, and page-load sync (#99) (#101)

* [25aa5b24] feat(rate-limits): add types, Zustand store, Axios 429 interceptor, WS hook, sync hook, and banner component

- panel/src/types/rate-limits.ts: RateLimitEntry, RateLimitHitEvent, RateLimitLiftedEvent, RateLimitApiResponse
- panel/src/store/rate-limit-store.ts: useRateLimitStore with Map state, hitRateLimit/liftRateLimit/syncFromApi
- panel/src/lib/api/rate-limits.ts: GET /api/system/rate-limits with isMockMode guard
- panel/src/lib/api/client.ts: 429 interceptor dispatches to store first, Sonner toast on retry exhaustion
- panel/src/hooks/use-rate-limit-websocket.ts: RATE_LIMIT_HIT/LIFTED events + onReconnect callback
- panel/src/hooks/use-rate-limit-sync.ts: mount sync + no-op with console.warn when endpoint unavailable
- panel/src/components/rate-limit/rate-limit-banner.tsx: amber rows with countdown, no dismiss button
- panel/src/app/(dashboard)/layout.tsx: RateLimitBanner mounted below Header
- store/index.ts, hooks/index.ts: export new store and hooks

* [25aa5b24] fix(rate-limit-banner): use lint-clean countdown pattern (computeSecondsLeft outside render)

* [25aa5b24] fix(client): add real retry loop to 429 interceptor so Sonner toast fires on exhaustion

- Increment error.config._retryCount and return api(error.config) when
  retryCount < RATE_LIMIT_MAX_RETRIES, actually retrying the request.
- Toast fires only when retryCount >= RATE_LIMIT_MAX_RETRIES (3 attempts).
- Fixes AC4: toast was dead code because without return api(error.config)
  every 429 saw retryCount=1, permanently below the threshold of 3.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [4112cd34] feat(rate-limit): add RateLimitError with 5-retry exponential backoff at all LLM call sites (#102) (#103)

- Create roboco/services/exceptions.py with RateLimitError(provider, retry_after),
  HTTP_TOO_MANY_REQUESTS, MAX_RATE_LIMIT_RETRIES constants, and
  parse_retry_after_header() helper
- extraction.py: extract _call_anthropic_with_retry() helper; retry Anthropic
  call 5x on 429 with exponential backoff; re-raise RateLimitError from outer
  except instead of swallowing it
- ollama_embedder.py: 5-retry outer loop (429) wrapping existing 3-retry inner
  loop (ConnectError/Timeout) for all 4 call sites; two concerns kept isolated
- indexes/base.py, mentor.py, validator.py: replace magic 429 literals with
  HTTP_TOO_MANY_REQUESTS; 5-retry loop on 429 for LLM calls
- middleware.py: add rate_limit_exception_handler returning HTTP 429 with
  Retry-After response header
- tests/unit/services/test_rate_limit_retry.py: 28 tests covering exhaustion,
  Retry-After header sleep, partial retries then success, ConnectError isolation

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [18107054] feat(rate-limit): Redis rate-limit state tracker + i_am_blocked rate_limited path (#105) (#106)

- Add RateLimitStateTracker in roboco/services/gateway/rate_limit_tracker.py
  with activate(), clear(), is_rate_limited(), get_state(),
  increment_probe_failures(), reset_probe_failures() backed by redis.asyncio
- Add RATE_LIMIT_HIT = "rate_limit.hit" to EventType StrEnum in events.py
- Add _handle_rate_limited_parking() to Choreographer: intercepts
  i_am_blocked(reason='rate_limited') before block state transition,
  parks all active agents sharing affected provider via mark_waiting_long,
  publishes RATE_LIMIT_HIT event to StreamEventBus, task stays in_progress
- Add get_provider_for_agent() and get_active_agent_slugs_for_provider()
  helper methods to AgentOrchestrator
- Wire orchestrator and stream_bus into ChoreographerDeps via deps.py
- Add test_rate_limit_tracker.py (basic ops, probe failures, cross-reconnection
  persistence, provider isolation) and test_i_am_blocked_rate_limited.py
  (AC3/AC4/AC5 coverage: task stays in_progress, mark_waiting_long call count
  equals active agent count, RATE_LIMIT_HIT event payload structure)

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [5501e4b4] Wire RateLimitStateTracker into live orchestrator paths — 4 CEO-identified integration gaps (#109)

* [8451ca50] feat(gateway): wire RateLimitStateTracker.activate() into i_am_blocked rate-limited path and add provider-rate-limit gate to decide_spawn() (#107)

- Add provider/provider_rate_limited optional fields to TriggerContext (backward-compatible defaults)
- Insert rule 2 in decide_spawn(): QUEUE when trigger.provider_rate_limited is True with reason 'provider X rate-limited'
- Call RateLimitStateTracker(provider).activate() in _handle_rate_limited_parking() after mark_waiting_long loop (wrapped in contextlib.suppress for Redis fault tolerance)
- Extend gateway_pre_spawn_check() with optional provider param; check RateLimitStateTracker.is_rate_limited() when provider is known
- Pass provider=self.get_provider_for_agent(agent_id) from orchestrator call site
- Add TestProviderRateLimitGate (6 tests) to test_trigger_filter.py
- Add TestRateLimitTrackerActivateOnParking (6 tests) to test_i_am_blocked_rate_limited.py
- All 38 unit tests pass; ruff and mypy clean on changed files

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [e9cef0f0] feat(rate-limits): sweeper probe loop, CEO notification, and GET /api/system/rate-limits endpoint (AC4, AC8, AC9) (#108)

- Add RATE_LIMIT_LIFTED event type to EventType enum in models/events.py
- Add RateLimitStateTracker.list_rate_limited_providers() classmethod to scan
  Redis for all currently rate-limited providers (used by the new endpoint)
- Add orchestrator._rate_limit_probe_loop(): background task started/stopped in
  start()/stop(), runs _sweep_rate_limit_probes() every 30s
- Add orchestrator._probe_one_provider(): checks estimated_lift_at gate, calls
  _do_probe(); on success: tracker.clear(), resolve_wait() for all parked agents
  with waiting_for='rate_limit_lifted' matching the provider, publishes
  RATE_LIMIT_LIFTED event; on failure: increments probe_failures counter, sends
  CEO notification at threshold 10 (once per episode via _rate_limit_ceo_notified)
- Add orchestrator._make_tracker(): injectable factory for RateLimitStateTracker
- Add orchestrator._do_probe(): overridable async bool probe (default: True)
- Add orchestrator._notify_rate_limit_ceo(): high-priority notification to CEO
  containing provider name, duration since activation, and paused agent count
- Add roboco/api/routes/system.py with GET /rate-limits endpoint (AC9)
- Register system_router in app.py under /api/system prefix
- Add 17 unit tests in tests/unit/runtime/test_rate_limit_sweep.py covering
  all AC4/AC8/AC9 paths: probe success/failure, CEO threshold, endpoint schema

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-11 09:21:26 +02:00

422 lines
13 KiB
Python

"""
API Middleware
Request/response middleware for logging, error handling, and correlation IDs.
"""
import time
import uuid
from collections.abc import Callable, Sequence
from typing import Any, cast
import structlog
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi import status as http_status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from roboco.api.schemas.common import ErrorCode
from roboco.exceptions import (
AuthenticationError,
InvalidStateError,
NotFoundError,
PermissionDeniedError,
RobocoError,
ValidationError,
)
from roboco.services.base import (
ConflictError as ServiceConflictError,
)
from roboco.services.base import (
NotFoundError as ServiceNotFoundError,
)
from roboco.services.base import (
ServiceError,
ServiceUnavailableError,
)
from roboco.services.base import (
UnauthorizedError as ServiceUnauthorizedError,
)
from roboco.services.base import (
ValidationError as ServiceValidationError,
)
from roboco.services.exceptions import RateLimitError
logger = structlog.get_logger()
# =============================================================================
# CORRELATION ID MIDDLEWARE
# =============================================================================
class CorrelationIdMiddleware(BaseHTTPMiddleware):
"""
Adds a correlation ID to each request for tracing.
The correlation ID is:
- Extracted from X-Correlation-ID header if present
- Generated if not present
- Added to response headers
- Bound to the logger context
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Get or generate correlation ID
correlation_id = request.headers.get("X-Correlation-ID")
if not correlation_id:
correlation_id = str(uuid.uuid4())
# Store in request state for access in handlers
request.state.correlation_id = correlation_id
# Bind to structlog context
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
correlation_id=correlation_id,
path=request.url.path,
method=request.method,
)
# Process request
response = cast("Response", await call_next(request))
# Add correlation ID to response
response.headers["X-Correlation-ID"] = correlation_id
return response
# =============================================================================
# REQUEST LOGGING MIDDLEWARE
# =============================================================================
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""
Logs request/response details with timing.
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
start_time = time.perf_counter()
# Log request
logger.info(
"Request started",
path=request.url.path,
method=request.method,
query_params=dict(request.query_params),
)
try:
response = cast("Response", await call_next(request))
duration_ms = (time.perf_counter() - start_time) * 1000
# Log response
logger.info(
"Request completed",
status_code=response.status_code,
duration_ms=round(duration_ms, 2),
)
# Add timing header
response.headers["X-Response-Time-Ms"] = str(round(duration_ms, 2))
return response
except Exception as e:
duration_ms = (time.perf_counter() - start_time) * 1000
logger.exception(
"Request failed",
duration_ms=round(duration_ms, 2),
error=str(e),
)
raise
# =============================================================================
# EXCEPTION HANDLERS
# =============================================================================
def get_status_code(exc: RobocoError) -> int:
"""Map exception type to HTTP status code."""
status_map = {
NotFoundError: 404,
ValidationError: 422,
InvalidStateError: 409,
PermissionDeniedError: 403,
AuthenticationError: 401,
}
for exc_type, status in status_map.items():
if isinstance(exc, exc_type):
return status
# Default for other RobocoError subclasses
return 400
async def roboco_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle RobocoError exceptions."""
roboco_exc = cast("RobocoError", exc)
status_code = get_status_code(roboco_exc)
# Add correlation ID to error details
correlation_id = getattr(request.state, "correlation_id", None)
if correlation_id:
roboco_exc.details["correlation_id"] = correlation_id
logger.warning(
"Handled exception",
error_code=roboco_exc.code,
error_message=roboco_exc.message,
status_code=status_code,
)
return JSONResponse(
status_code=status_code,
content=roboco_exc.to_dict(),
)
# `roboco.services.base.ServiceError` is a parallel exception hierarchy that
# does NOT inherit from `RobocoError` (it extends `Exception` directly), so
# `roboco_exception_handler` never sees it and the requests fall through to
# `generic_exception_handler` as 500s. Map its subclasses to the same status
# codes used in the RobocoError handler so route-layer try/except blocks can
# surface clean 4xx codes whether the service raises from `roboco.exceptions`
# or `roboco.services.base`.
_SERVICE_ERROR_STATUS: dict[type[ServiceError], int] = {
ServiceNotFoundError: 404,
ServiceValidationError: 422,
ServiceConflictError: 409,
ServiceUnauthorizedError: 403,
ServiceUnavailableError: 503,
}
async def service_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle `roboco.services.base.ServiceError` and subclasses."""
svc_exc = cast("ServiceError", exc)
status_code = 500
for exc_type, mapped_status in _SERVICE_ERROR_STATUS.items():
if isinstance(svc_exc, exc_type):
status_code = mapped_status
break
correlation_id = getattr(request.state, "correlation_id", None)
details = dict(svc_exc.details)
if correlation_id:
details["correlation_id"] = correlation_id
logger.warning(
"Handled exception",
error_type=type(svc_exc).__name__,
error_message=svc_exc.message,
status_code=status_code,
)
return JSONResponse(
status_code=status_code,
content={
"error": type(svc_exc).__name__,
"message": svc_exc.message,
"details": details,
},
)
async def rate_limit_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""Handle :class:`~roboco.services.exceptions.RateLimitError`.
Returns HTTP 429 with a ``Retry-After`` response header (when available)
and a structured JSON body so API consumers can back off gracefully.
"""
rl_exc = cast("RateLimitError", exc)
correlation_id = getattr(request.state, "correlation_id", None)
logger.warning(
"LLM rate limit exhausted",
provider=rl_exc.provider,
retry_after=rl_exc.retry_after,
)
content: dict = {
"error": "rate_limit_exceeded",
"provider": rl_exc.provider,
"message": str(rl_exc),
}
if correlation_id:
content["correlation_id"] = correlation_id
headers: dict[str, str] = {}
if rl_exc.retry_after is not None:
headers["Retry-After"] = str(int(rl_exc.retry_after))
return JSONResponse(
status_code=429,
content=content,
headers=headers,
)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle unexpected exceptions."""
correlation_id = getattr(request.state, "correlation_id", None)
logger.exception(
"Unhandled exception",
error=str(exc),
error_type=type(exc).__name__,
)
return JSONResponse(
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": {
"code": ErrorCode.INTERNAL_ERROR,
"message": "An internal error occurred",
"details": {
"correlation_id": correlation_id,
},
}
},
)
# Map HTTP status codes to string error codes
_HTTP_TO_ERROR_CODE: dict[int, str] = {
400: ErrorCode.INVALID_INPUT,
401: ErrorCode.NOT_AUTHORIZED,
403: ErrorCode.ACCESS_DENIED,
404: ErrorCode.NOT_FOUND,
409: ErrorCode.INVALID_INPUT, # Conflict
422: ErrorCode.INVALID_INPUT, # Validation error
500: ErrorCode.INTERNAL_ERROR,
}
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""
Handle FastAPI HTTPException with standardized error format.
Converts HTTP status codes to string error codes for consistency with MCP.
"""
http_exc = cast("HTTPException", exc)
correlation_id = getattr(request.state, "correlation_id", None)
# Map status code to error code
error_code = _HTTP_TO_ERROR_CODE.get(http_exc.status_code, ErrorCode.INTERNAL_ERROR)
logger.warning(
"HTTP exception",
status_code=http_exc.status_code,
error_code=error_code,
detail=http_exc.detail,
)
response_content: dict = {
"error": {
"code": error_code,
"message": str(http_exc.detail),
}
}
if correlation_id:
response_content["error"]["details"] = {"correlation_id": correlation_id}
return JSONResponse(
status_code=http_exc.status_code,
content=response_content,
)
# =============================================================================
# SETUP FUNCTION
# =============================================================================
def _uuid_field_remediation(errors: Sequence[Any]) -> str | None:
"""Spell out the fix when a truncated id is sent where a UUID is required.
Agents routinely copy the 8-character task prefix the system shows them
(e.g. the ``[cee99ecc]`` commit prefix) and send it as ``task_id``, which
fails UUID validation with an opaque "invalid length" message and wastes a
call. Detect that case and hand back an actionable remediation instead.
"""
for err in errors:
if not isinstance(err, dict):
continue
loc = err.get("loc") or ()
field = loc[-1] if loc else None
if field == "task_id" and "uuid" in str(err.get("type", "")).lower():
return (
"Use the FULL 36-character task UUID, not the 8-character short "
"form shown in commit prefixes or summaries. The full id is in "
"the `task_id` field of the envelope returned by give_me_work "
"or your most recent verb."
)
return None
async def request_validation_handler(request: Request, exc: Exception) -> JSONResponse:
"""Log the rejected body before returning the standard 422 response.
FastAPI's default 422 returns validation details to the client but
nothing lands in server logs. During smoke tests this leaves us
blind to which field actually broke. Log the body + the per-field
errors so the next 422 is debuggable in one log scan.
When the failure is a truncated ``task_id`` (the recurring agent mistake),
add a ``remediate`` hint so the agent knows to retry with the full UUID.
"""
rve = cast("RequestValidationError", exc)
body = rve.body if isinstance(rve.body, str | bytes | dict | list) else None
errors = rve.errors()
logger.warning(
"Request validation failed",
path=request.url.path,
method=request.method,
body=body,
errors=errors,
)
content: dict[str, Any] = {"detail": errors, "body": body}
remediate = _uuid_field_remediation(errors)
if remediate is not None:
content["remediate"] = remediate
return JSONResponse(
status_code=http_status.HTTP_422_UNPROCESSABLE_ENTITY,
content=content,
)
def setup_middleware(app: FastAPI) -> None:
"""
Setup all middleware for the application.
Order matters:
1. CorrelationIdMiddleware - first to set correlation ID
2. RequestLoggingMiddleware - logs with correlation ID
Exception handler priority:
1. RequestValidationError - 422s; log body + per-field errors
2. HTTPException - most common, converts to string error codes
3. RobocoError - custom domain exceptions
4. Exception - catch-all for unexpected errors
"""
# Exception handlers (order: specific to general)
app.add_exception_handler(RequestValidationError, request_validation_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RobocoError, roboco_exception_handler)
app.add_exception_handler(ServiceError, service_exception_handler)
app.add_exception_handler(RateLimitError, rate_limit_exception_handler)
app.add_exception_handler(Exception, generic_exception_handler)
# Middleware (added in reverse order due to LIFO)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CorrelationIdMiddleware)