feat(coordinator-api): add global exception handler and rate limiting to marketplace and exchange endpoints

- Add general exception handler to catch all unhandled exceptions with structured error responses
- Add structured logging to validation error handler with request context
- Implement slowapi rate limiting on marketplace endpoints (100/min list, 50/min stats, 30/min bid)
- Implement slowapi rate limiting on exchange payment creation (20/min)
- Add Request parameter to rate-limited endpoints for slow
This commit is contained in:
oib
2026-02-28 21:22:37 +01:00
parent 7cb0b30dae
commit f05195749c
5 changed files with 566 additions and 10 deletions

View File

@@ -148,6 +148,35 @@ def create_app() -> FastAPI:
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle all unhandled exceptions with structured error responses."""
request_id = request.headers.get("X-Request-ID")
logger.error(f"Unhandled exception: {exc}", extra={
"request_id": request_id,
"path": request.url.path,
"method": request.method,
"error_type": type(exc).__name__
})
error_response = ErrorResponse(
error={
"code": "INTERNAL_SERVER_ERROR",
"message": "An unexpected error occurred",
"status": 500,
"details": [{
"field": "internal",
"message": str(exc),
"code": type(exc).__name__
}]
},
request_id=request_id
)
return JSONResponse(
status_code=500,
content=error_response.model_dump()
)
@app.exception_handler(AITBCError)
async def aitbc_error_handler(request: Request, exc: AITBCError) -> JSONResponse:
"""Handle AITBC exceptions with structured error responses."""
@@ -162,6 +191,13 @@ def create_app() -> FastAPI:
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Handle FastAPI validation errors with structured error responses."""
request_id = request.headers.get("X-Request-ID")
logger.warning(f"Validation error: {exc}", extra={
"request_id": request_id,
"path": request.url.path,
"method": request.method,
"validation_errors": exc.errors()
})
details = []
for error in exc.errors():
details.append({

View File

@@ -3,14 +3,17 @@ Bitcoin Exchange Router for AITBC
"""
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
import uuid
import time
import json
import os
from slowapi import Limiter
from slowapi.util import get_remote_address
from aitbc.logging import get_logger
logger = get_logger(__name__)
limiter = Limiter(key_func=get_remote_address)
from ..schemas import (
ExchangePaymentRequest,
@@ -38,30 +41,32 @@ BITCOIN_CONFIG = {
}
@router.post("/exchange/create-payment", response_model=ExchangePaymentResponse)
@limiter.limit("20/minute")
async def create_payment(
request: ExchangePaymentRequest,
request: Request,
payment_request: ExchangePaymentRequest,
background_tasks: BackgroundTasks
) -> Dict[str, Any]:
"""Create a new Bitcoin payment request"""
# Validate request
if request.aitbc_amount <= 0 or request.btc_amount <= 0:
if payment_request.aitbc_amount <= 0 or payment_request.btc_amount <= 0:
raise HTTPException(status_code=400, detail="Invalid amount")
# Calculate expected BTC amount
expected_btc = request.aitbc_amount / BITCOIN_CONFIG['exchange_rate']
expected_btc = payment_request.aitbc_amount / BITCOIN_CONFIG['exchange_rate']
# Allow small difference for rounding
if abs(request.btc_amount - expected_btc) > 0.00000001:
if abs(payment_request.btc_amount - expected_btc) > 0.00000001:
raise HTTPException(status_code=400, detail="Amount mismatch")
# Create payment record
payment_id = str(uuid.uuid4())
payment = {
'payment_id': payment_id,
'user_id': request.user_id,
'aitbc_amount': request.aitbc_amount,
'btc_amount': request.btc_amount,
'user_id': payment_request.user_id,
'aitbc_amount': payment_request.aitbc_amount,
'btc_amount': payment_request.btc_amount,
'payment_address': BITCOIN_CONFIG['main_address'],
'status': 'pending',
'created_at': int(time.time()),

View File

@@ -1,13 +1,18 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import status as http_status
from slowapi import Limiter
from slowapi.util import get_remote_address
from ..schemas import MarketplaceBidRequest, MarketplaceOfferView, MarketplaceStatsView, MarketplaceBidView
from ..services import MarketplaceService
from ..storage import SessionDep
from ..metrics import marketplace_requests_total, marketplace_errors_total
from aitbc.logging import get_logger
logger = get_logger(__name__)
limiter = Limiter(key_func=get_remote_address)
router = APIRouter(tags=["marketplace"])
@@ -20,7 +25,9 @@ def _get_service(session: SessionDep) -> MarketplaceService:
response_model=list[MarketplaceOfferView],
summary="List marketplace offers",
)
@limiter.limit("100/minute")
async def list_marketplace_offers(
request: Request,
*,
session: SessionDep,
status_filter: str | None = Query(default=None, alias="status", description="Filter by offer status"),
@@ -44,7 +51,12 @@ async def list_marketplace_offers(
response_model=MarketplaceStatsView,
summary="Get marketplace summary statistics",
)
async def get_marketplace_stats(*, session: SessionDep) -> MarketplaceStatsView:
@limiter.limit("50/minute")
async def get_marketplace_stats(
request: Request,
*,
session: SessionDep
) -> MarketplaceStatsView:
marketplace_requests_total.labels(endpoint="/marketplace/stats", method="GET").inc()
service = _get_service(session)
try:
@@ -59,7 +71,9 @@ async def get_marketplace_stats(*, session: SessionDep) -> MarketplaceStatsView:
status_code=http_status.HTTP_202_ACCEPTED,
summary="Submit a marketplace bid",
)
@limiter.limit("30/minute")
async def submit_marketplace_bid(
request: Request,
payload: MarketplaceBidRequest,
session: SessionDep,
) -> dict[str, str]: