From 84255ef0a8577a401ebe75273e803e0649be87d9 Mon Sep 17 00:00:00 2001 From: Kevin Nowald Date: Thu, 11 Jun 2026 22:50:28 +0200 Subject: [PATCH] feat: standardize API error responses on RFC 9457 problem details --- backend/app/api/routes/v1/auth.py | 12 +- backend/app/api/routes/v1/events.py | 15 +- backend/app/api/routes/v1/import_xml.py | 7 +- backend/app/api/routes/v1/oauth.py | 10 +- .../app/api/routes/v1/outgoing_webhooks.py | 12 +- backend/app/api/routes/v1/sdk_logs.py | 6 +- backend/app/api/routes/v1/sdk_sync.py | 9 +- backend/app/api/routes/v1/sdk_token.py | 6 +- backend/app/api/routes/v1/sync_data.py | 27 +- backend/app/api/routes/v1/sync_status.py | 5 +- backend/app/api/routes/v1/users.py | 24 +- backend/app/api/routes/v1/vendor_workouts.py | 9 +- backend/app/main.py | 24 +- backend/app/services/api_key_service.py | 11 +- .../apple/apple_xml/presigned_url_service.py | 36 +- backend/app/services/application_service.py | 11 +- backend/app/services/invitation_service.py | 33 +- .../app/services/provider_settings_service.py | 3 +- backend/app/services/providers/api_client.py | 34 +- .../providers/templates/base_oauth.py | 35 +- backend/app/services/refresh_token_service.py | 12 +- backend/app/services/services.py | 9 +- .../services/user_invitation_code_service.py | 6 +- backend/app/utils/auth.py | 19 +- backend/app/utils/exceptions.py | 56 ++- backend/app/utils/problem.py | 166 +++++++++ backend/tests/api/v1/test_auth.py | 15 +- backend/tests/api/v1/test_connections.py | 10 +- backend/tests/api/v1/test_dashboard.py | 4 +- backend/tests/api/v1/test_error_format.py | 325 ++++++++++++++++++ backend/tests/api/v1/test_oauth.py | 9 +- backend/tests/api/v1/test_sdk_token.py | 16 +- backend/tests/api/v1/test_sync_data.py | 2 +- .../tests/api/v1/test_user_invitation_code.py | 4 +- backend/tests/api/v1/test_users.py | 10 +- backend/tests/api/v1/test_vendor_workouts.py | 4 +- backend/tests/api/v1/test_workouts.py | 4 +- .../test_provider_settings_service.py | 5 +- backend/tests/utils_tests/test_exceptions.py | 96 +++--- .../api-reference/guides/apple-xml-import.mdx | 5 +- docs/api-reference/guides/error-handling.mdx | 167 +++++++-- .../guides/sync-status-stream.mdx | 23 +- docs/api-reference/introduction.mdx | 8 +- docs/dev-guides/integration-guide.mdx | 40 ++- frontend/src/hooks/use-oauth-connect.ts | 6 +- frontend/src/lib/api/client.ts | 12 +- frontend/src/lib/errors/api-error.ts | 44 ++- frontend/src/routeTree.gen.ts | 2 +- 48 files changed, 1121 insertions(+), 287 deletions(-) create mode 100644 backend/app/utils/problem.py create mode 100644 backend/tests/api/v1/test_error_format.py diff --git a/backend/app/api/routes/v1/auth.py b/backend/app/api/routes/v1/auth.py index 46d352740..ee705f05e 100644 --- a/backend/app/api/routes/v1/auth.py +++ b/backend/app/api/routes/v1/auth.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from fastapi.security import OAuth2PasswordRequestForm from app.config import settings @@ -8,6 +8,7 @@ from app.schemas.auth import TokenResponse from app.schemas.model_crud.user_management import DeveloperRead, DeveloperUpdate, PasswordChange from app.services import DeveloperDep, developer_service, refresh_token_service +from app.utils.exceptions import ApiError from app.utils.security import create_access_token, verify_password router = APIRouter() @@ -28,16 +29,18 @@ def login( sort_by=None, ) if not developers: - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="INVALID_CREDENTIALS", detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) developer = developers[0] if not verify_password(form_data.password, developer.hashed_password): - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="INVALID_CREDENTIALS", detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) @@ -68,8 +71,9 @@ def change_password( """Change password for the current authenticated developer.""" # Verify the current password if not verify_password(payload.current_password, developer.hashed_password): - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INCORRECT_CURRENT_PASSWORD", detail="Incorrect current password", ) diff --git a/backend/app/api/routes/v1/events.py b/backend/app/api/routes/v1/events.py index d4ca294ff..9b41227a7 100644 --- a/backend/app/api/routes/v1/events.py +++ b/backend/app/api/routes/v1/events.py @@ -1,7 +1,7 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Query, status from app.database import DbSession from app.schemas.model_crud.activities import EventRecordQueryParams @@ -14,6 +14,7 @@ from app.services import ApiKeyDep from app.services.event_record_service import event_record_service from app.utils.dates import DateTimeQueryParam, parse_query_datetime +from app.utils.exceptions import ApiError router = APIRouter() @@ -89,7 +90,7 @@ def delete_workout( ) -> None: """Delete a workout session.""" if not event_record_service.delete_event_record(db, user_id, workout_id, "workout"): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found") + raise ApiError(status_code=status.HTTP_404_NOT_FOUND, code="WORKOUT_NOT_FOUND", detail="Workout not found") @router.delete("/users/{user_id}/events/sleep/{sleep_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -101,7 +102,9 @@ def delete_sleep_session( ) -> None: """Delete a sleep session.""" if not event_record_service.delete_event_record(db, user_id, sleep_id, "sleep"): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sleep session not found") + raise ApiError( + status_code=status.HTTP_404_NOT_FOUND, code="SLEEP_SESSION_NOT_FOUND", detail="Sleep session not found" + ) @router.delete("/users/{user_id}/events/menstrual-cycles/{cycle_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -113,4 +116,8 @@ def delete_menstrual_cycle( ) -> None: """Delete a menstrual cycle record.""" if not event_record_service.delete_event_record(db, user_id, cycle_id, "menstrual_cycle"): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Menstrual cycle record not found") + raise ApiError( + status_code=status.HTTP_404_NOT_FOUND, + code="MENSTRUAL_CYCLE_NOT_FOUND", + detail="Menstrual cycle record not found", + ) diff --git a/backend/app/api/routes/v1/import_xml.py b/backend/app/api/routes/v1/import_xml.py index d96e3d361..358a3c9f2 100644 --- a/backend/app/api/routes/v1/import_xml.py +++ b/backend/app/api/routes/v1/import_xml.py @@ -1,7 +1,7 @@ import json from json import JSONDecodeError -from fastapi import APIRouter, HTTPException, Request, UploadFile, status +from fastapi import APIRouter, Request, UploadFile, status from pydantic import ValidationError from app.integrations.celery.tasks.process_xml_upload_task import process_xml_upload @@ -14,6 +14,7 @@ from app.services import ApiKeyDep from app.services.apple.apple_xml.presigned_url_service import presigned_url_service from app.services.apple.apple_xml.sns_service import sns_service +from app.utils.exceptions import ApiError router = APIRouter() @@ -56,10 +57,10 @@ async def receive_sns_notification( try: notification = SNSNotification.model_validate(json.loads(body)) except (ValidationError, JSONDecodeError) as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + raise ApiError(status_code=status.HTTP_400_BAD_REQUEST, code="INVALID_SNS_NOTIFICATION", detail=str(e)) result = await sns_service.handle_sns_notification(notification) if result.status_code not in (status.HTTP_200_OK, status.HTTP_202_ACCEPTED): - raise HTTPException(status_code=result.status_code, detail=result.response) + raise ApiError(status_code=result.status_code, code="SNS_NOTIFICATION_FAILED", detail=result.response) return result diff --git a/backend/app/api/routes/v1/oauth.py b/backend/app/api/routes/v1/oauth.py index 46459edad..c639d0fbe 100644 --- a/backend/app/api/routes/v1/oauth.py +++ b/backend/app/api/routes/v1/oauth.py @@ -2,7 +2,7 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Query, status from fastapi.responses import RedirectResponse from app.config import settings @@ -18,6 +18,7 @@ from app.services.provider_settings_service import ProviderSettingsService from app.services.providers.base_strategy import BaseProviderStrategy from app.services.providers.factory import ProviderFactory +from app.utils.exceptions import ApiError, UnsupportedProviderError router = APIRouter() factory = ProviderFactory() @@ -29,8 +30,9 @@ def get_oauth_strategy(provider: ProviderName) -> BaseProviderStrategy: strategy = factory.get_provider(provider.value) if not strategy.oauth: - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="UNSUPPORTED_PROVIDER_OPERATION", detail=f"Provider '{provider.value}' does not support OAuth", ) return strategy @@ -184,8 +186,10 @@ def update_provider_setting( """Update is_enabled and/or live_sync_mode for a single provider.""" try: return settings_service.update_provider_setting(db, provider, update) + except UnsupportedProviderError as e: + raise ApiError(status_code=status.HTTP_400_BAD_REQUEST, code=e.code, detail=e.detail) except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + raise ApiError(status_code=status.HTTP_400_BAD_REQUEST, code="INVALID_PROVIDER", detail=str(e)) @router.put("/providers", response_model=list[ProviderSettingRead], tags=["Internal: Providers"]) diff --git a/backend/app/api/routes/v1/outgoing_webhooks.py b/backend/app/api/routes/v1/outgoing_webhooks.py index d6b601867..f274f0bbd 100644 --- a/backend/app/api/routes/v1/outgoing_webhooks.py +++ b/backend/app/api/routes/v1/outgoing_webhooks.py @@ -13,7 +13,7 @@ from datetime import datetime from typing import Annotated, Any -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Query, status from svix.api import EndpointOut, MessageAttemptListByEndpointOptions, MessageListOptions, MessageStatus from app.schemas.webhooks.endpoints import ( @@ -30,6 +30,7 @@ from app.schemas.webhooks.event_types import EVENT_TYPE_DESCRIPTIONS, EVENT_TYPE_GROUPS, WebhookEventType from app.services import DeveloperDep from app.services.outgoing_webhooks import svix as svix_service +from app.utils.exceptions import ApiError router = APIRouter() @@ -47,8 +48,9 @@ def _ep_to_response(ep: EndpointOut) -> EndpointResponse: def _svix_app_id(developer: DeveloperDep) -> str: """Authenticate the developer, assert Svix is configured, and return the app UID.""" if not svix_service.is_enabled(): - raise HTTPException( + raise ApiError( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="WEBHOOKS_NOT_CONFIGURED", detail="Outgoing webhooks are not configured (set SVIX_JWT_SECRET or SVIX_AUTH_TOKEN).", ) return svix_service.ensure_application(str(developer.id), developer.email) @@ -226,5 +228,9 @@ def send_test_event(endpoint_id: str, app_id: SvixAppId, body: TestEventRequest event_type = body.event_type if body else WebhookEventType.WORKOUT_CREATED result = svix_service.send_test_message(app_id, endpoint_id, event_type) if result is None: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send test event.") + raise ApiError( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="WEBHOOK_TEST_FAILED", + detail="Failed to send test event.", + ) return {"message": "Test event sent successfully.", "message_id": result.id} diff --git a/backend/app/api/routes/v1/sdk_logs.py b/backend/app/api/routes/v1/sdk_logs.py index 137da2372..03b5a60c8 100644 --- a/backend/app/api/routes/v1/sdk_logs.py +++ b/backend/app/api/routes/v1/sdk_logs.py @@ -1,12 +1,13 @@ import uuid from logging import getLogger -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, status from app.schemas.providers.mobile_sdk import SDKLogRequest from app.schemas.responses.upload import UploadDataResponse from app.services.raw_payload_storage import store_raw_payload from app.utils.auth import SDKAuthDep +from app.utils.exceptions import ApiError from app.utils.structured_logging import log_structured router = APIRouter() @@ -25,8 +26,9 @@ def submit_sdk_logs( lifecycle, device state, sync success/failure). """ if auth.auth_type == "sdk_token" and (not auth.user_id or str(auth.user_id) != user_id): - raise HTTPException( + raise ApiError( status_code=status.HTTP_403_FORBIDDEN, + code="PERMISSION_DENIED", detail="Token does not match user_id", ) diff --git a/backend/app/api/routes/v1/sdk_sync.py b/backend/app/api/routes/v1/sdk_sync.py index 5003c2dc7..3908b9372 100644 --- a/backend/app/api/routes/v1/sdk_sync.py +++ b/backend/app/api/routes/v1/sdk_sync.py @@ -1,13 +1,14 @@ import uuid from logging import getLogger -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, status from app.integrations.celery.tasks.process_sdk_upload_task import process_sdk_upload from app.schemas.providers.mobile_sdk import SyncRequest from app.schemas.responses.upload import UploadDataResponse from app.services.raw_payload_storage import store_raw_payload from app.utils.auth import SDKAuthDep +from app.utils.exceptions import ApiError from app.utils.structured_logging import log_structured router = APIRouter() @@ -48,8 +49,9 @@ def sync_sdk_data( HTTPException: 403 if token doesn't match user_id, 400 if provider unsupported """ if auth.auth_type == "sdk_token" and (not auth.user_id or str(auth.user_id) != user_id): - raise HTTPException( + raise ApiError( status_code=status.HTTP_403_FORBIDDEN, + code="PERMISSION_DENIED", detail="Token does not match user_id", ) @@ -58,8 +60,9 @@ def sync_sdk_data( # Validate provider if provider not in ("apple", "samsung", "google"): - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_PROVIDER", detail=f"Unsupported provider: {provider}. Supported: apple, samsung, google", ) diff --git a/backend/app/api/routes/v1/sdk_token.py b/backend/app/api/routes/v1/sdk_token.py index 26bb71fdf..b021acedb 100644 --- a/backend/app/api/routes/v1/sdk_token.py +++ b/backend/app/api/routes/v1/sdk_token.py @@ -1,13 +1,14 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Body, HTTPException, status +from fastapi import APIRouter, Body, status from app.config import settings from app.database import DbSession from app.schemas.auth import SDKTokenRequest, TokenResponse from app.services import application_service, create_sdk_user_token, refresh_token_service from app.utils.auth import DeveloperOptionalDep +from app.utils.exceptions import ApiError router = APIRouter() @@ -57,8 +58,9 @@ def create_user_token( app_id = f"admin:{developer.id}" else: # Neither method provided - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="MISSING_APP_CREDENTIALS", detail="Either app credentials (app_id, app_secret) or admin authentication (Bearer token) is required", ) diff --git a/backend/app/api/routes/v1/sync_data.py b/backend/app/api/routes/v1/sync_data.py index a2b078eee..63c65f69c 100644 --- a/backend/app/api/routes/v1/sync_data.py +++ b/backend/app/api/routes/v1/sync_data.py @@ -4,7 +4,7 @@ from typing import Annotated, Any from uuid import UUID -from fastapi import APIRouter, HTTPException, Path, Query, status +from fastapi import APIRouter, Path, Query, status from app.database import DbSession from app.integrations.celery.tasks import ( @@ -18,7 +18,7 @@ from app.schemas.enums import ProviderName from app.services import ApiKeyDep from app.services.providers.factory import ProviderFactory -from app.utils.exceptions import UnsupportedProviderError +from app.utils.exceptions import ApiError, UnsupportedProviderError logger = logging.getLogger(__name__) @@ -136,8 +136,9 @@ def sync_user_data( } unsupported = [k for k, v in non_default_params.items() if v] if unsupported: - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="UNSUPPORTED_SYNC_PARAMETERS", detail=( f"Parameters {unsupported} are not supported in async mode. " "Use async=false or omit provider-specific parameters." @@ -181,8 +182,9 @@ def sync_user_data( if strategy.workouts: results["workouts"] = strategy.workouts.load_data(db, user_id, **params) elif data_type == SyncDataType.WORKOUTS: - raise HTTPException( + raise ApiError( status_code=status.HTTP_501_NOT_IMPLEMENTED, + code="UNSUPPORTED_PROVIDER_OPERATION", detail=f"Provider '{provider.value}' does not support workouts", ) @@ -198,14 +200,16 @@ def sync_user_data( end_dt = datetime.now() results["data_247"] = load_fn(db, user_id, start_time=start_dt, end_time=end_dt) elif data_type == SyncDataType.DATA_247: - raise HTTPException( + raise ApiError( status_code=status.HTTP_501_NOT_IMPLEMENTED, + code="UNSUPPORTED_PROVIDER_OPERATION", detail=f"Provider '{provider.value}' does not support 247 data (sleep/recovery/activity)", ) if not results: - raise HTTPException( + raise ApiError( status_code=status.HTTP_501_NOT_IMPLEMENTED, + code="UNSUPPORTED_PROVIDER_OPERATION", detail=f"Provider '{provider.value}' does not support any requested data types", ) @@ -271,8 +275,9 @@ def cancel_garmin_backfill( """ backfill_status = get_garmin_backfill_status(str(user_id)) if backfill_status["overall_status"] not in ("in_progress", "retry_in_progress"): - raise HTTPException( + raise ApiError( status_code=status.HTTP_409_CONFLICT, + code="BACKFILL_NOT_IN_PROGRESS", detail="No backfill in progress for this user", ) @@ -305,8 +310,9 @@ def retry_garmin_backfill_type( Dict with retry status """ if type_name not in GARMIN_BACKFILL_DATA_TYPES: - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_BACKFILL_TYPE", detail=f"Invalid type: {type_name}. Valid types: {', '.join(GARMIN_BACKFILL_DATA_TYPES)}", ) @@ -371,10 +377,11 @@ def sync_historical_data( try: result = strategy.start_historical_sync(user_id, days) except UnsupportedProviderError as exc: - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code=exc.code, detail=exc.detail, - ) + ) from exc return { "success": True, diff --git a/backend/app/api/routes/v1/sync_status.py b/backend/app/api/routes/v1/sync_status.py index ac203d0f2..11ccd76f6 100644 --- a/backend/app/api/routes/v1/sync_status.py +++ b/backend/app/api/routes/v1/sync_status.py @@ -23,7 +23,7 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Query, status from fastapi.responses import StreamingResponse from app.database import DbSession @@ -35,6 +35,7 @@ get_run_summaries, stream_user_events, ) +from app.utils.exceptions import ApiError logger = logging.getLogger(__name__) @@ -50,7 +51,7 @@ def _ensure_user_exists(db: DbSession, user_id: UUID) -> None: user = user_service.get(db, user_id) if user is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + raise ApiError(status_code=status.HTTP_404_NOT_FOUND, code="USER_NOT_FOUND", detail="User not found") @router.get( diff --git a/backend/app/api/routes/v1/users.py b/backend/app/api/routes/v1/users.py index 8ed3d60d3..13ffdc135 100644 --- a/backend/app/api/routes/v1/users.py +++ b/backend/app/api/routes/v1/users.py @@ -33,21 +33,31 @@ async def list_users( 401: { "description": "Authentication required", "content": { - "application/json": {"example": {"detail": "Authentication required: provide JWT token or API key"}} + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/Problem"}, + "example": { + "title": "Unauthorized", + "status": 401, + "detail": "Authentication required: provide JWT token or API key", + "code": "NOT_AUTHENTICATED", + }, + } }, }, 404: { "description": "User not found", "content": { - "application/json": { - "example": {"detail": "User with ID: 123e4567-e89b-12d3-a456-426614174000 not found."} + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/Problem"}, + "example": { + "title": "Not Found", + "status": 404, + "detail": "User with ID: 123e4567-e89b-12d3-a456-426614174000 not found.", + "code": "USER_NOT_FOUND", + }, } }, }, - 400: { - "description": "Validation error", - "content": {"application/json": {"example": {"detail": "Input should be a valid UUID"}}}, - }, }, ) def get_user(user_id: UUID, db: DbSession, _api_key: ApiKeyDep): diff --git a/backend/app/api/routes/v1/vendor_workouts.py b/backend/app/api/routes/v1/vendor_workouts.py index 0b0bfbf27..2dd05a9c0 100644 --- a/backend/app/api/routes/v1/vendor_workouts.py +++ b/backend/app/api/routes/v1/vendor_workouts.py @@ -1,12 +1,13 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, HTTPException, Path, Query, status +from fastapi import APIRouter, Path, Query, status from app.database import DbSession from app.schemas.enums import ProviderName from app.services import ApiKeyDep from app.services.providers.factory import ProviderFactory +from app.utils.exceptions import ApiError router = APIRouter() factory = ProviderFactory() @@ -58,8 +59,9 @@ def get_user_workouts( strategy = factory.get_provider(provider.value) if not strategy.workouts: - raise HTTPException( + raise ApiError( status_code=status.HTTP_501_NOT_IMPLEMENTED, + code="UNSUPPORTED_PROVIDER_OPERATION", detail=f"Provider '{provider.value}' does not support workouts", ) @@ -103,8 +105,9 @@ def get_user_workout_detail( strategy = factory.get_provider(provider.value) if not strategy.workouts: - raise HTTPException( + raise ApiError( status_code=status.HTTP_501_NOT_IMPLEMENTED, + code="UNSUPPORTED_PROVIDER_OPERATION", detail=f"Provider '{provider.value}' does not support workouts", ) diff --git a/backend/app/main.py b/backend/app/main.py index fbe5131b8..a513ed67f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,9 +5,7 @@ from logging import INFO, StreamHandler, basicConfig from pathlib import Path -from fastapi import FastAPI, Request, status -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from app.api import head_router @@ -17,7 +15,7 @@ from app.middlewares import add_cors_middleware from app.services import raw_payload_storage from app.services.outgoing_webhooks import svix as svix_service -from app.utils.exceptions import DatetimeParseError, handle_exception +from app.utils.problem import apply_problem_openapi, register_exception_handlers # Configure logging to use stdout instead of stderr # Some platforms convert stderr logs to level.error automatically, so we must use stdout @@ -67,21 +65,7 @@ async def root() -> dict[str, str]: return {"message": "Server is running!"} -@api.exception_handler(RequestValidationError) -async def request_validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: - # (FastAPI ≥ 0.130 rejects empty required str form fields before the handler runs) - if request.url.path.endswith("/auth/login"): - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"detail": "Incorrect email or password"}, - headers={"WWW-Authenticate": "Bearer"}, - ) - raise handle_exception(exc, "") - - -@api.exception_handler(DatetimeParseError) -async def datetime_parse_exception_handler(_: Request, exc: DatetimeParseError) -> None: - raise handle_exception(exc, "") - +register_exception_handlers(api) api.include_router(head_router) +apply_problem_openapi(api) diff --git a/backend/app/services/api_key_service.py b/backend/app/services/api_key_service.py index 778fd0caa..465b3e117 100644 --- a/backend/app/services/api_key_service.py +++ b/backend/app/services/api_key_service.py @@ -3,7 +3,7 @@ from typing import Annotated from uuid import UUID -from fastapi import Depends, Header, HTTPException +from fastapi import Depends, Header from app.database import DbSession from app.models import ApiKey, Developer @@ -11,6 +11,7 @@ from app.schemas.model_crud.credentials import ApiKeyCreate, ApiKeyUpdate from app.services.services import AppService from app.utils.auth import get_current_developer_optional +from app.utils.exceptions import ApiError class ApiKeyService(AppService[ApiKeyRepository, ApiKey, ApiKeyCreate, ApiKeyUpdate]): @@ -49,7 +50,7 @@ def rotate_api_key(self, db: DbSession, old_key: str, created_by: UUID | None) - def validate_api_key(self, db: DbSession, key: str) -> ApiKey: """Validate API key exists in database. Raises 401 if invalid.""" if not (api_key := self.get(db, key)): - raise HTTPException(status_code=401, detail="Invalid or missing API key") + raise ApiError(status_code=401, code="INVALID_API_KEY", detail="Invalid or missing API key") return api_key @@ -65,7 +66,11 @@ async def _require_api_key( return str(developer.id) if x_open_wearables_api_key: return api_key_service.validate_api_key(db, x_open_wearables_api_key).id - raise HTTPException(status_code=401, detail="Authentication required: provide JWT token or API key") + raise ApiError( + status_code=401, + code="NOT_AUTHENTICATED", + detail="Authentication required: provide JWT token or API key", + ) ApiKeyDep = Annotated[str, Depends(_require_api_key)] diff --git a/backend/app/services/apple/apple_xml/presigned_url_service.py b/backend/app/services/apple/apple_xml/presigned_url_service.py index 8d65fd3d4..39e48f44b 100644 --- a/backend/app/services/apple/apple_xml/presigned_url_service.py +++ b/backend/app/services/apple/apple_xml/presigned_url_service.py @@ -2,13 +2,14 @@ from logging import Logger, getLogger from botocore.exceptions import ClientError -from fastapi import HTTPException, status +from fastapi import status from app.schemas.providers.apple.apple_xml import ( PresignedURLRequest, PresignedURLResponse, ) from app.services.apple.apple_xml.aws_service import AWS_BUCKET_NAME, get_s3_client +from app.utils.exceptions import ApiError class PresignedURLService: @@ -31,7 +32,11 @@ def generate_file_key(self, user_id: str, filename: str | None = None) -> str: def validate_bucket_exists(self) -> bool: """Check if the S3 bucket exists and is accessible""" if not self.s3_client: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 client not configured") + raise ApiError( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="S3_NOT_CONFIGURED", + detail="S3 client not configured", + ) try: self.s3_client.head_bucket(Bucket=AWS_BUCKET_NAME) @@ -40,17 +45,30 @@ def validate_bucket_exists(self) -> bool: except ClientError as e: error_code = e.response["Error"]["Code"] if error_code == "404": - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="S3 bucket not found") from e + raise ApiError( + status_code=status.HTTP_404_NOT_FOUND, + code="S3_BUCKET_NOT_FOUND", + detail="S3 bucket not found", + ) from e if error_code == "403": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to S3 bucket") from e - raise HTTPException( + raise ApiError( + status_code=status.HTTP_403_FORBIDDEN, + code="S3_ACCESS_DENIED", + detail="Access denied to S3 bucket", + ) from e + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="S3_BUCKET_ERROR", detail=f"S3 bucket error: {error_code}", ) from e def create_presigned_url(self, user_id: str, request: PresignedURLRequest) -> PresignedURLResponse: if not self.s3_client: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 client not configured") + raise ApiError( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="S3_NOT_CONFIGURED", + detail="S3 client not configured", + ) self.validate_bucket_exists() @@ -86,13 +104,15 @@ def create_presigned_url(self, user_id: str, request: PresignedURLRequest) -> Pr except ClientError as e: error_code = e.response["Error"]["Code"] - raise HTTPException( + raise ApiError( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="PRESIGNED_URL_GENERATION_FAILED", detail=f"Failed to generate presigned URL: {error_code}", ) from e except Exception as e: - raise HTTPException( + raise ApiError( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="PRESIGNED_URL_GENERATION_FAILED", detail=f"Unexpected error: {str(e)}", ) from e diff --git a/backend/app/services/application_service.py b/backend/app/services/application_service.py index 8de76a76f..89e07ef41 100644 --- a/backend/app/services/application_service.py +++ b/backend/app/services/application_service.py @@ -3,8 +3,6 @@ from logging import Logger, getLogger from uuid import UUID -from fastapi import HTTPException - from app.database import DbSession from app.models import Application from app.repositories.application_repository import ApplicationRepository @@ -13,6 +11,7 @@ ApplicationUpdate, ) from app.services.services import AppService +from app.utils.exceptions import ApiError from app.utils.security import get_password_hash, verify_password from app.utils.structured_logging import log_structured @@ -72,7 +71,7 @@ def validate_credentials(self, db: DbSession, app_id: str, app_secret: str) -> A f"Application not found: {app_id}", extra={"app_id": app_id}, ) - raise HTTPException(status_code=401, detail="Invalid app credentials") + raise ApiError(status_code=401, code="INVALID_APP_CREDENTIALS", detail="Invalid app credentials") if not verify_password(app_secret, application.app_secret_hash): log_structured( @@ -82,7 +81,7 @@ def validate_credentials(self, db: DbSession, app_id: str, app_secret: str) -> A action="validate_credentials", app_id=app_id, ) - raise HTTPException(status_code=401, detail="Invalid app credentials") + raise ApiError(status_code=401, code="INVALID_APP_CREDENTIALS", detail="Invalid app credentials") return application @@ -100,7 +99,7 @@ def delete_application(self, db: DbSession, app_id: str, developer_id: UUID) -> """ application = self.crud.get_by_app_id(db, app_id) if not application or application.developer_id != developer_id: - raise HTTPException(status_code=404, detail="Application not found") + raise ApiError(status_code=404, code="APPLICATION_NOT_FOUND", detail="Application not found") self.delete(db, application.id, raise_404=True) self.logger.debug(f"Deleted application {app_id}") @@ -116,7 +115,7 @@ def rotate_secret(self, db: DbSession, app_id: str, developer_id: UUID) -> tuple """ application = self.crud.get_by_app_id(db, app_id) if not application or application.developer_id != developer_id: - raise HTTPException(status_code=404, detail="Application not found") + raise ApiError(status_code=404, code="APPLICATION_NOT_FOUND", detail="Application not found") new_secret = self._generate_app_secret() new_hash = get_password_hash(new_secret) diff --git a/backend/app/services/invitation_service.py b/backend/app/services/invitation_service.py index 2e9a52ff5..6db2cad16 100644 --- a/backend/app/services/invitation_service.py +++ b/backend/app/services/invitation_service.py @@ -4,7 +4,7 @@ from logging import Logger, getLogger from uuid import UUID, uuid4 -from fastapi import HTTPException, status +from fastapi import status from app.config import settings from app.database import DbSession @@ -18,6 +18,7 @@ InvitationStatus, ) from app.services.developer_service import developer_service +from app.utils.exceptions import ApiError from app.utils.security import get_password_hash from app.utils.structured_logging import log_structured @@ -67,16 +68,18 @@ def create_invitation( sort_by=None, ) if existing_developers: - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="DEVELOPER_ALREADY_EXISTS", detail="A developer with this email already exists", ) # Check for existing pending invitation existing_invitation = self.crud.get_by_email(db_session, payload.email) if existing_invitation: - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVITATION_ALREADY_EXISTS", detail="A pending invitation already exists for this email", ) @@ -119,21 +122,24 @@ def accept_invitation( invitation = self.crud.get_by_token(db_session, token) if not invitation: - raise HTTPException( + raise ApiError( status_code=status.HTTP_404_NOT_FOUND, + code="INVITATION_NOT_FOUND", detail="Invitation not found", ) if invitation.status not in (InvitationStatus.PENDING, InvitationStatus.SENT): - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_INVITATION_STATUS", detail=f"Invitation is {invitation.status}", ) if invitation.expires_at < datetime.now(timezone.utc): self.crud.update_status(db_session, invitation, InvitationStatus.EXPIRED) - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVITATION_EXPIRED", detail="Invitation has expired", ) @@ -158,8 +164,9 @@ def accept_invitation( provider="invitation", task="accept_invitation", ) - raise HTTPException( + raise ApiError( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="INTERNAL_ERROR", detail="Failed to create developer account", ) @@ -171,14 +178,16 @@ def revoke_invitation(self, db_session: DbSession, invitation_id: UUID) -> Invit invitation = self.crud.get(db_session, invitation_id) if not invitation: - raise HTTPException( + raise ApiError( status_code=status.HTTP_404_NOT_FOUND, + code="INVITATION_NOT_FOUND", detail="Invitation not found", ) if invitation.status not in (InvitationStatus.PENDING, InvitationStatus.SENT, InvitationStatus.FAILED): - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_INVITATION_STATUS", detail=f"Cannot revoke invitation with status: {invitation.status}", ) @@ -192,14 +201,16 @@ def resend_invitation(self, db_session: DbSession, invitation_id: UUID) -> Invit invitation = self.crud.get(db_session, invitation_id) if not invitation: - raise HTTPException( + raise ApiError( status_code=status.HTTP_404_NOT_FOUND, + code="INVITATION_NOT_FOUND", detail="Invitation not found", ) if invitation.status not in (InvitationStatus.PENDING, InvitationStatus.SENT, InvitationStatus.FAILED): - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_INVITATION_STATUS", detail=f"Cannot resend invitation with status: {invitation.status}", ) diff --git a/backend/app/services/provider_settings_service.py b/backend/app/services/provider_settings_service.py index 60a1692c1..2b239dbf7 100644 --- a/backend/app/services/provider_settings_service.py +++ b/backend/app/services/provider_settings_service.py @@ -10,6 +10,7 @@ ProviderSettingUpdate, ) from app.services.providers.factory import ProviderFactory +from app.utils.exceptions import UnsupportedProviderError _REGISTER_WEBHOOKS_TASK = "app.integrations.celery.tasks.register_provider_webhooks_task.register_provider_webhooks" @@ -56,7 +57,7 @@ def update_provider_setting( raise ValueError(f"Unknown provider: {provider}") if update.live_sync_mode is not None and not strategy.live_sync_configurable: - raise ValueError(f"Provider '{provider}' does not support live sync mode configuration") + raise UnsupportedProviderError(provider, "live sync mode configuration") db_settings_map = self.repo.get_all(db) current = db_settings_map.get(provider) diff --git a/backend/app/services/providers/api_client.py b/backend/app/services/providers/api_client.py index ed149db26..074eff46e 100644 --- a/backend/app/services/providers/api_client.py +++ b/backend/app/services/providers/api_client.py @@ -12,6 +12,7 @@ from app.database import DbSession from app.repositories import UserConnectionRepository from app.services.providers.templates.base_oauth import BaseOAuthTemplate +from app.utils.exceptions import ApiError from app.utils.structured_logging import log_structured logger = logging.getLogger(__name__) @@ -34,23 +35,26 @@ def _get_valid_token( """ connection = connection_repo.get_by_user_and_provider(db, user_id, provider_name) if not connection: - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="PROVIDER_NOT_CONNECTED", detail=f"User not connected to {provider_name}", ) # SDK-based providers don't have access tokens if not connection.access_token: - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="PROVIDER_TOKEN_MISSING", detail=f"No access token available for {provider_name} (SDK-based provider?)", ) # Check if token is expired (with 5 minute buffer) if connection.token_expires_at and connection.token_expires_at < datetime.now(timezone.utc) + timedelta(minutes=5): if not connection.refresh_token: - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="PROVIDER_AUTHORIZATION_EXPIRED", detail=f"Token expired and no refresh token available for {provider_name}", ) token_response = oauth.refresh_access_token(db, user_id, connection.refresh_token) @@ -147,8 +151,9 @@ def make_authenticated_request( attempt=attempt + 1, max_retries=MAX_RETRIES, ) - raise HTTPException( + raise ApiError( status_code=429, + code="PROVIDER_RATE_LIMITED", detail=f"{provider_name.capitalize()} API error: {response.text}", ) @@ -179,8 +184,9 @@ def make_authenticated_request( provider_name=provider_name, error_msg=error_msg, ) - raise HTTPException( + raise ApiError( status_code=result.get("code", 400), + code="PROVIDER_API_ERROR", detail=f"{provider_name.capitalize()} API error: {error_msg}", ) @@ -211,12 +217,14 @@ def make_authenticated_request( error=e.response.text, ) if e.response.status_code == 401: - raise HTTPException( + raise ApiError( status_code=401, + code="PROVIDER_AUTHORIZATION_EXPIRED", detail=f"{provider_name.capitalize()} authorization expired. Please re-authorize.", ) - raise HTTPException( + raise ApiError( status_code=e.response.status_code, + code="PROVIDER_API_ERROR", detail=f"{provider_name.capitalize()} API error: {e.response.text}", ) except HTTPException: @@ -231,14 +239,16 @@ def make_authenticated_request( user_id=str(user_id), error=str(e), ) - raise HTTPException( + raise ApiError( status_code=500, + code="PROVIDER_REQUEST_FAILED", detail=f"Failed to fetch data from {provider_name.capitalize()}: {str(e)}", ) # Should not reach here, but just in case - raise HTTPException( + raise ApiError( status_code=500, + code="PROVIDER_REQUEST_FAILED", detail=f"Failed to complete request to {provider_name.capitalize()} after retries", ) @@ -278,4 +288,8 @@ def download_binary_content( response.raise_for_status() return response.content - raise HTTPException(status_code=500, detail=f"Failed to download binary content from {provider_name}") + raise ApiError( + status_code=500, + code="PROVIDER_REQUEST_FAILED", + detail=f"Failed to download binary content from {provider_name}", + ) diff --git a/backend/app/services/providers/templates/base_oauth.py b/backend/app/services/providers/templates/base_oauth.py index b6ab7c903..457faf755 100644 --- a/backend/app/services/providers/templates/base_oauth.py +++ b/backend/app/services/providers/templates/base_oauth.py @@ -9,7 +9,6 @@ from uuid import UUID import httpx -from fastapi import HTTPException from redis import Redis from starlette.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR @@ -26,6 +25,7 @@ ) from app.schemas.model_crud.user_management import UserConnectionCreate from app.services.outgoing_webhooks.events import on_connection_created +from app.utils.exceptions import ApiError from app.utils.structured_logging import log_structured logger = logging.getLogger(__name__) @@ -114,7 +114,11 @@ def handle_callback(self, db: DbSession, code: str, state: str) -> OAuthState: user_id=str(oauth_state.user_id), state_provider=oauth_state.provider, ) - raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Provider mismatch in state") + raise ApiError( + status_code=HTTP_400_BAD_REQUEST, + code="INVALID_OAUTH_STATE", + detail="Provider mismatch in state", + ) token_response = self._exchange_token(code, code_verifier) @@ -178,7 +182,11 @@ def refresh_access_token(self, db: DbSession, user_id: UUID, refresh_token: str) user_id=str(user_id), status_code=e.response.status_code, ) - raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=f"Failed to refresh token: {e.response.text}") + raise ApiError( + status_code=HTTP_400_BAD_REQUEST, + code="PROVIDER_TOKEN_REFRESH_FAILED", + detail=f"Failed to refresh token: {e.response.text}", + ) except Exception as e: log_structured( logger, @@ -188,7 +196,11 @@ def refresh_access_token(self, db: DbSession, user_id: UUID, refresh_token: str) task="refresh_access_token", user_id=str(user_id), ) - raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Token refresh failed: {str(e)}") + raise ApiError( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + code="PROVIDER_TOKEN_REFRESH_FAILED", + detail=f"Token refresh failed: {str(e)}", + ) def _build_auth_url(self, state: str) -> tuple[str, dict[str, Any] | None]: """Builds the authorization URL. @@ -236,7 +248,11 @@ def _validate_state(self, state: str) -> tuple[OAuthState, str | None]: state_data = self.redis_client.get(redis_key) if not state_data: - raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Invalid or expired state parameter") + raise ApiError( + status_code=HTTP_400_BAD_REQUEST, + code="INVALID_OAUTH_STATE", + detail="Invalid or expired state parameter", + ) # Delete state immediately (one-time use) self.redis_client.delete(redis_key) @@ -272,8 +288,9 @@ def _exchange_token(self, code: str, code_verifier: str | None) -> OAuthTokenRes task="exchange_token", status_code=e.response.status_code, ) - raise HTTPException( + raise ApiError( status_code=HTTP_400_BAD_REQUEST, + code="PROVIDER_TOKEN_EXCHANGE_FAILED", detail=f"Failed to exchange authorization code: {e.response.text}", ) except Exception as e: @@ -284,7 +301,11 @@ def _exchange_token(self, code: str, code_verifier: str | None) -> OAuthTokenRes provider=self.provider_name, task="exchange_token", ) - raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Token exchange failed: {str(e)}") + raise ApiError( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + code="PROVIDER_TOKEN_EXCHANGE_FAILED", + detail=f"Token exchange failed: {str(e)}", + ) def _prepare_token_request(self, code: str, code_verifier: str | None) -> tuple[dict, dict]: """Prepares the token exchange request. Default implementation uses Basic Auth.""" diff --git a/backend/app/services/refresh_token_service.py b/backend/app/services/refresh_token_service.py index 7a9dfc3fc..5d80fbcce 100644 --- a/backend/app/services/refresh_token_service.py +++ b/backend/app/services/refresh_token_service.py @@ -3,7 +3,7 @@ from logging import Logger, getLogger from uuid import UUID -from fastapi import HTTPException, status +from fastapi import status from app.config import settings from app.database import DbSession @@ -11,6 +11,7 @@ from app.repositories.refresh_token_repository import refresh_token_repository from app.schemas.auth import TokenResponse, TokenType from app.services.sdk_token_service import create_sdk_user_token +from app.utils.exceptions import ApiError from app.utils.security import create_access_token @@ -95,8 +96,9 @@ def refresh_token(self, db_session: DbSession, refresh_token_str: str) -> TokenR """ token = self.repo.get_valid_token(db_session, refresh_token_str) if not token: - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="INVALID_REFRESH_TOKEN", detail="Invalid or revoked refresh token", headers={"WWW-Authenticate": "Bearer"}, ) @@ -124,8 +126,9 @@ def refresh_token(self, db_session: DbSession, refresh_token_str: str) -> TokenR ) self.logger.debug(f"Refreshed developer token for developer {token.developer_id} (rotated)") else: - raise HTTPException( + raise ApiError( status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_TOKEN_TYPE", detail=f"Unknown token type: {token.token_type}", ) @@ -151,8 +154,9 @@ def revoke_token(self, db_session: DbSession, refresh_token_str: str) -> bool: """ token = self.repo.get_valid_token(db_session, refresh_token_str) if not token: - raise HTTPException( + raise ApiError( status_code=status.HTTP_404_NOT_FOUND, + code="REFRESH_TOKEN_NOT_FOUND", detail="Refresh token not found", ) diff --git a/backend/app/services/services.py b/backend/app/services/services.py index e563eefb0..f02d43980 100644 --- a/backend/app/services/services.py +++ b/backend/app/services/services.py @@ -7,7 +7,7 @@ from app.database import BaseDbModel, DbSession from app.repositories.repositories import CrudRepository from app.schemas.utils import FilterParams -from app.utils.exceptions import ResourceNotFoundError, handle_exceptions +from app.utils.exceptions import ResourceNotFoundError, handle_exceptions, not_found_code type OptRequest = Request | None @@ -29,6 +29,9 @@ def __init__( ): self.crud = crud_model(model) self.name = self.crud.model.__name__.lower() + # Derive from the CamelCase model name so word boundaries survive, + # e.g. API_KEY_NOT_FOUND rather than APIKEY_NOT_FOUND + self.not_found_code = not_found_code(self.crud.model.__name__) self.logger = log super().__init__(**kwargs) @@ -55,7 +58,7 @@ def get( id_to_fetch = object_id if not (fetched := self.crud.get(db_session, id_to_fetch)) and raise_404: # ty:ignore[invalid-argument-type] - raise ResourceNotFoundError(self.name, id_to_fetch) # ty:ignore[invalid-argument-type] + raise ResourceNotFoundError(self.name, id_to_fetch, code=self.not_found_code) # ty:ignore[invalid-argument-type] if fetched and print_log: self.logger.debug(f"Fetched {self.name} with ID: {fetched.id}.") @@ -84,7 +87,7 @@ def get_all( ) if not fetched and raise_404: - raise ResourceNotFoundError(self.name) + raise ResourceNotFoundError(self.name, code=self.not_found_code) self.logger.debug(f"Fetched {len(fetched)} {self.name}s. Filters: {filter_params.filters}.") diff --git a/backend/app/services/user_invitation_code_service.py b/backend/app/services/user_invitation_code_service.py index 8760be0ef..7f6ff04fd 100644 --- a/backend/app/services/user_invitation_code_service.py +++ b/backend/app/services/user_invitation_code_service.py @@ -3,7 +3,7 @@ from logging import Logger, getLogger from uuid import UUID, uuid4 -from fastapi import HTTPException, status +from fastapi import status from app.config import settings from app.database import DbSession @@ -17,6 +17,7 @@ from app.services.refresh_token_service import refresh_token_service from app.services.sdk_token_service import create_sdk_user_token from app.services.user_service import user_service +from app.utils.exceptions import ApiError CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" CODE_LENGTH = 8 @@ -58,8 +59,9 @@ def redeem(self, db_session: DbSession, code: str) -> InvitationCodeRedeemRespon invitation_code = self.crud.get_valid_by_code(db_session, code.upper()) if not invitation_code: - raise HTTPException( + raise ApiError( status_code=status.HTTP_404_NOT_FOUND, + code="INVALID_INVITATION_CODE", detail="Invalid or expired invitation code", ) diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index e76c742cf..ac42eef54 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -1,7 +1,7 @@ from typing import Annotated from uuid import UUID -from fastapi import Depends, Header, HTTPException, status +from fastapi import Depends, Header, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt @@ -10,6 +10,7 @@ from app.models import Developer from app.repositories.developer_repository import DeveloperRepository from app.schemas.auth import SDKAuthContext +from app.utils.exceptions import ApiError oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) developer_repository = DeveloperRepository(Developer) @@ -23,22 +24,29 @@ async def get_current_developer( SDK-scoped tokens are rejected - they can only access /sdk/ endpoints. """ - credentials_exception = HTTPException( + credentials_exception = ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="INVALID_TOKEN", detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) if not token: - raise credentials_exception + raise ApiError( + status_code=status.HTTP_401_UNAUTHORIZED, + code="NOT_AUTHENTICATED", + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) try: payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) # Reject SDK-scoped tokens - they can ONLY access /sdk/ endpoints if payload.get("scope") == "sdk": - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="INVALID_TOKEN", detail="SDK tokens cannot access this endpoint", headers={"WWW-Authenticate": "Bearer"}, ) @@ -133,8 +141,9 @@ async def get_sdk_auth( api_key = api_key_service.validate_api_key(db, x_open_wearables_api_key) return SDKAuthContext(auth_type="api_key", api_key_id=api_key.id) - raise HTTPException( + raise ApiError( status_code=status.HTTP_401_UNAUTHORIZED, + code="NOT_AUTHENTICATED", detail="Authentication required: provide SDK token or API key", ) diff --git a/backend/app/utils/exceptions.py b/backend/app/utils/exceptions.py index c0b518c91..895653746 100644 --- a/backend/app/utils/exceptions.py +++ b/backend/app/utils/exceptions.py @@ -1,10 +1,11 @@ import inspect +import re from collections.abc import Awaitable, Callable from functools import singledispatch, wraps from typing import TYPE_CHECKING, overload from uuid import UUID -from fastapi.exceptions import HTTPException, RequestValidationError +from fastapi.exceptions import HTTPException from psycopg.errors import IntegrityError as PsycopgIntegrityError from sqlalchemy.exc import IntegrityError as SQLAIntegrityError @@ -12,15 +13,39 @@ from app.services import AppService +class ApiError(HTTPException): + """HTTPException with a stable machine-readable error code. + + Rendered as an RFC 9457 problem details response by the handler in + app.utils.problem. + """ + + def __init__(self, status_code: int, code: str, detail: str, headers: dict[str, str] | None = None): + super().__init__(status_code=status_code, detail=detail, headers=headers) + self.code = code + + +def not_found_code(entity_name: str) -> str: + """Derive an error code from an entity name, e.g. ApiKey -> API_KEY_NOT_FOUND.""" + words = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", entity_name) + normalized = re.sub(r"[^A-Z0-9]+", "_", words.upper()).strip("_") + # Dynamic entity descriptions (IDs, dates) would produce unbounded codes + if not normalized or len(normalized) > 30: + return "RESOURCE_NOT_FOUND" + return f"{normalized}_NOT_FOUND" + + class UnsupportedProviderError(Exception): def __init__(self, provider: str, operation: str = "this operation"): self.detail = f"Provider '{provider}' does not support {operation}." + self.code = "UNSUPPORTED_PROVIDER_OPERATION" super().__init__(self.detail) class ResourceNotFoundError(Exception): - def __init__(self, entity_name: str, entity_id: int | UUID | None = None): + def __init__(self, entity_name: str, entity_id: int | UUID | None = None, code: str | None = None): self.entity_name = entity_name + self.code = code or not_found_code(entity_name) if entity_id: self.detail = f"{entity_name.capitalize()} with ID: {entity_id} not found." else: @@ -45,45 +70,42 @@ def handle_exception(exc: Exception, _: str) -> HTTPException: @handle_exception.register def _(exc: SQLAIntegrityError | PsycopgIntegrityError, entity: str) -> HTTPException: - return HTTPException( + return ApiError( status_code=400, + code="ALREADY_EXISTS", detail=f"{entity.capitalize()} entity already exists. Details: {exc.args[0]}", ) @handle_exception.register def _(exc: ResourceNotFoundError, _: str) -> HTTPException: - return HTTPException(status_code=404, detail=exc.detail) + return ApiError(status_code=404, code=exc.code, detail=exc.detail) + + +@handle_exception.register +def _(exc: UnsupportedProviderError, _: str) -> HTTPException: + return ApiError(status_code=400, code=exc.code, detail=exc.detail) @handle_exception.register def _(exc: InvalidCursorError, _: str) -> HTTPException: - return HTTPException(status_code=400, detail=exc.detail) + return ApiError(status_code=400, code="INVALID_CURSOR", detail=exc.detail) @handle_exception.register def _(exc: DatetimeParseError, _: str) -> HTTPException: - return HTTPException(status_code=400, detail=exc.detail) + return ApiError(status_code=400, code="INVALID_DATETIME", detail=exc.detail) @handle_exception.register def _(exc: AttributeError, entity: str) -> HTTPException: - return HTTPException( + return ApiError( status_code=400, + code="UNSUPPORTED_ATTRIBUTE", detail=f"{entity.capitalize()} doesn't support attribute or method. Details: {exc.args[0]} ", ) -@handle_exception.register -def _(exc: RequestValidationError, _: str) -> HTTPException: - err_args = exc.args[0][0] - msg = err_args.get("msg", "Validation error") - ctx = err_args.get("ctx", {}) - error = ctx.get("error", "") if ctx else "" - detail = f"{msg} - {error}" if error else msg - return HTTPException(status_code=400, detail=detail) - - @overload def handle_exceptions[**P, T, Service: AppService]( func: Callable[P, Awaitable[T]], diff --git a/backend/app/utils/problem.py b/backend/app/utils/problem.py new file mode 100644 index 000000000..190f5d46c --- /dev/null +++ b/backend/app/utils/problem.py @@ -0,0 +1,166 @@ +"""RFC 9457 problem details error responses. + +Every error response uses the shape {title, status, detail, code} with the +`application/problem+json` media type. `code` is an extension member carrying +a stable machine-readable identifier. `type` is omitted, which RFC 9457 +defines as equivalent to "about:blank". +""" + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.openapi.utils import get_openapi +from fastapi.responses import JSONResponse, Response +from fastapi.utils import is_body_allowed_for_status_code +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.utils.exceptions import DatetimeParseError + +PROBLEM_SCHEMA: dict[str, Any] = { + "title": "Problem", + "type": "object", + "description": "RFC 9457 problem details error response", + "properties": { + "title": {"type": "string", "title": "Title"}, + "status": {"type": "integer", "title": "Status"}, + "detail": {"type": "string", "title": "Detail"}, + "code": {"type": "string", "title": "Code"}, + "errors": { + "type": "array", + "title": "Errors", + "items": { + "type": "object", + "properties": { + "field": {"type": "string", "title": "Field"}, + "message": {"type": "string", "title": "Message"}, + "type": {"type": "string", "title": "Type"}, + }, + "required": ["field", "message", "type"], + }, + }, + }, + "required": ["title", "status", "detail", "code"], +} + + +def _status_phrase(status_code: int) -> str: + try: + return HTTPStatus(status_code).phrase + except ValueError: + return "Error" + + +def _status_code_name(status_code: int) -> str: + try: + return HTTPStatus(status_code).name + except ValueError: + return f"HTTP_{status_code}" + + +def problem_response( + status_code: int, + code: str, + detail: str, + *, + errors: list[dict[str, str]] | None = None, + headers: Mapping[str, str] | None = None, +) -> JSONResponse: + content: dict[str, Any] = { + "title": _status_phrase(status_code), + "status": status_code, + "detail": detail, + "code": code, + } + if errors is not None: + content["errors"] = errors + return JSONResponse( + status_code=status_code, + content=content, + media_type="application/problem+json", + headers=headers, + ) + + +def register_exception_handlers(api: FastAPI) -> None: + @api.exception_handler(StarletteHTTPException) + async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> Response: + # 1xx/204/205/304 must not carry a body + if not is_body_allowed_for_status_code(exc.status_code): + return Response(status_code=exc.status_code, headers=exc.headers) + # ApiError carries an explicit code; plain HTTPException falls back to + # a code derived from the status, e.g. 401 -> UNAUTHORIZED. + code = getattr(exc, "code", None) or _status_code_name(exc.status_code) + detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail) + return problem_response(exc.status_code, code, detail, headers=exc.headers) + + @api.exception_handler(RequestValidationError) + async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + # (FastAPI >= 0.130 rejects empty required str form fields before the route runs) + if request.url.path.endswith("/auth/login"): + return problem_response( + status.HTTP_401_UNAUTHORIZED, + "INVALID_CREDENTIALS", + "Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + errors = [ + { + "field": ".".join(str(part) for part in error.get("loc", ())), + "message": error.get("msg", "Invalid value"), + "type": error.get("type", "value_error"), + } + for error in exc.errors() + ] + return problem_response( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "VALIDATION_ERROR", + "Request validation failed.", + errors=errors, + ) + + @api.exception_handler(DatetimeParseError) + async def handle_datetime_parse_error(_: Request, exc: DatetimeParseError) -> JSONResponse: + return problem_response(status.HTTP_400_BAD_REQUEST, "INVALID_DATETIME", exc.detail) + + @api.exception_handler(Exception) + async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse: + # Starlette sends this response and then re-raises the exception, so it + # still reaches the server log and Sentry. + return problem_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "An unexpected error occurred.", + ) + + +def apply_problem_openapi(api: FastAPI) -> None: + """Replace FastAPI's generated 422 validation schema with the Problem schema.""" + + def custom_openapi() -> dict[str, Any]: + if api.openapi_schema: + return api.openapi_schema + schema = get_openapi( + title=api.title, + version=api.version, + description=api.description, + routes=api.routes, + ) + schemas = schema.setdefault("components", {}).setdefault("schemas", {}) + schemas["Problem"] = PROBLEM_SCHEMA + schemas.pop("HTTPValidationError", None) + schemas.pop("ValidationError", None) + problem_content = {"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}} + for path_item in schema.get("paths", {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + for response_status, response in operation.get("responses", {}).items(): + if response_status == "422": + response["content"] = problem_content + api.openapi_schema = schema + return schema + + api.openapi = custom_openapi # ty:ignore[invalid-assignment] diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index dfb5f6c24..9244533c0 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -179,7 +179,9 @@ def test_change_password_invalid_current(self, client: TestClient, db: Session, response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) assert response.status_code == 400 - assert response.json()["detail"] == "Incorrect current password" + body = response.json() + assert body["code"] == "INCORRECT_CURRENT_PASSWORD" + assert body["detail"] == "Incorrect current password" def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Test failure when new_password and confirm_password do not match.""" @@ -193,8 +195,10 @@ def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_ response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) - assert response.status_code == 400 - assert "The confirmation password does not match" in str(response.json()) + assert response.status_code == 422 + body = response.json() + assert body["code"] == "VALIDATION_ERROR" + assert any("The confirmation password does not match" in error["message"] for error in body["errors"]) def test_change_password_too_short(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Test failure when new_password is too short.""" @@ -208,7 +212,10 @@ def test_change_password_too_short(self, client: TestClient, db: Session, api_v1 response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) - assert response.status_code == 400 + assert response.status_code == 422 + body = response.json() + assert body["code"] == "VALIDATION_ERROR" + assert any("at least" in error["message"] for error in body["errors"]) class TestGetCurrentDeveloper: diff --git a/backend/tests/api/v1/test_connections.py b/backend/tests/api/v1/test_connections.py index f49b74ae3..3f5f82b00 100644 --- a/backend/tests/api/v1/test_connections.py +++ b/backend/tests/api/v1/test_connections.py @@ -202,16 +202,16 @@ def test_get_connections_invalid_api_key(self, client: TestClient, db: Session) assert response.status_code == 401 def test_get_connections_invalid_user_id(self, client: TestClient, db: Session) -> None: - """Test handling of invalid user ID format returns 400.""" + """Test handling of invalid user ID format returns 422.""" # Arrange api_key = ApiKeyFactory() headers = api_key_headers(api_key.id) - # Act - FastAPI/Starlette validates UUID path params and returns 400 Bad Request + # Act - FastAPI validates UUID path params and returns 422 response = client.get("/api/v1/users/not-a-uuid/connections", headers=headers) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_get_connections_nonexistent_user(self, client: TestClient, db: Session) -> None: """Test retrieving connections for a user that doesn't exist.""" @@ -393,8 +393,8 @@ def test_disconnect_invalid_provider(self, client: TestClient, db: Session) -> N # Act response = client.delete(f"/api/v1/users/{user.id}/connections/not_a_provider", headers=headers) - # Assert - FastAPI returns 400 for invalid enum path params - assert response.status_code == 400 + # Assert - FastAPI returns 422 for invalid enum path params + assert response.status_code == 422 def test_disconnect_missing_api_key(self, client: TestClient, db: Session) -> None: """Test that request without API key is rejected.""" diff --git a/backend/tests/api/v1/test_dashboard.py b/backend/tests/api/v1/test_dashboard.py index c0965799b..25d5c59e5 100644 --- a/backend/tests/api/v1/test_dashboard.py +++ b/backend/tests/api/v1/test_dashboard.py @@ -231,8 +231,8 @@ def test_get_dashboard_stats_top_limit_parameter(self, client: TestClient, db: S # Act - test with invalid top_limit (should fail validation) response = client.get(f"{api_v1_prefix}/dashboard/stats?top_limit=0", headers=headers) - # Assert - app maps RequestValidationError to 400 - assert response.status_code == 400 + # Assert - validation errors are 422 + assert response.status_code == 422 def test_get_dashboard_stats_unauthorized(self, client: TestClient, api_v1_prefix: str) -> None: """Test getting dashboard stats fails without authentication.""" diff --git a/backend/tests/api/v1/test_error_format.py b/backend/tests/api/v1/test_error_format.py new file mode 100644 index 000000000..b0c62def0 --- /dev/null +++ b/backend/tests/api/v1/test_error_format.py @@ -0,0 +1,325 @@ +""" +Tests for the RFC 9457 problem details error format. + +Covers the handlers registered by app.utils.problem.register_exception_handlers +through a minimal FastAPI app, plus real endpoints migrated to ApiError. +""" + +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.utils.exceptions import ApiError, DatetimeParseError +from app.utils.problem import register_exception_handlers +from tests.factories import ApiKeyFactory, DeveloperFactory, UserFactory +from tests.utils import api_key_headers, developer_auth_headers + +PROBLEM_CONTENT_TYPE = "application/problem+json" + + +def build_problem_app() -> FastAPI: + """Build a minimal app exercising every registered exception handler.""" + app = FastAPI() + register_exception_handlers(app) + + class ItemPayload(BaseModel): + name: str = Field(min_length=5) + quantity: int + + @app.get("/api-error") + def raise_api_error() -> None: + raise ApiError(status_code=409, code="ITEM_CONFLICT", detail="Item already exists.") + + @app.get("/http-error") + def raise_http_exception() -> None: + raise HTTPException(status_code=404, detail="Item not found.") + + @app.post("/items") + def create_item(payload: ItemPayload) -> dict[str, bool]: + return {"ok": True} + + @app.get("/unhandled-error") + def raise_runtime_error() -> None: + raise RuntimeError("boom") + + @app.get("/datetime-error") + def raise_datetime_parse_error() -> None: + raise DatetimeParseError("not-a-datetime") + + @app.get("/auth-error") + def raise_api_error_with_headers() -> None: + raise ApiError( + status_code=401, + code="NOT_AUTHENTICATED", + detail="Authentication required.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return app + + +class TestProblemResponses: + """Test suite for the problem details handlers on a minimal app.""" + + def test_api_error_renders_problem_body(self) -> None: + """Test that ApiError renders the full problem body with its code.""" + # Arrange + test_client = TestClient(build_problem_app()) + + # Act + response = test_client.get("/api-error") + + # Assert + assert response.status_code == 409 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.json() == { + "title": "Conflict", + "status": 409, + "detail": "Item already exists.", + "code": "ITEM_CONFLICT", + } + + def test_plain_http_exception_derives_code_from_status(self) -> None: + """Test that plain HTTPException gets a code derived from the status.""" + # Arrange + test_client = TestClient(build_problem_app()) + + # Act + response = test_client.get("/http-error") + + # Assert + assert response.status_code == 404 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.json() == { + "title": "Not Found", + "status": 404, + "detail": "Item not found.", + "code": "NOT_FOUND", + } + + def test_validation_error_lists_all_errors(self) -> None: + """Test that a validation failure returns 422 with one entry per error.""" + # Arrange + test_client = TestClient(build_problem_app()) + + # Act - both fields are invalid + response = test_client.post("/items", json={"name": "ab", "quantity": "not-a-number"}) + + # Assert + assert response.status_code == 422 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + body = response.json() + assert body["title"] == "Unprocessable Content" + assert body["status"] == 422 + assert body["detail"] == "Request validation failed." + assert body["code"] == "VALIDATION_ERROR" + assert {error["field"] for error in body["errors"]} == {"body.name", "body.quantity"} + for error in body["errors"]: + assert set(error) == {"field", "message", "type"} + assert error["message"] + assert error["type"] + + def test_datetime_parse_error_returns_invalid_datetime(self) -> None: + """Test that DatetimeParseError renders a 400 with code INVALID_DATETIME.""" + # Arrange + test_client = TestClient(build_problem_app()) + + # Act + response = test_client.get("/datetime-error") + + # Assert + assert response.status_code == 400 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.json() == { + "title": "Bad Request", + "status": 400, + "detail": "Invalid datetime format: 'not-a-datetime'. Expected ISO 8601 format or Unix timestamp.", + "code": "INVALID_DATETIME", + } + + def test_api_error_preserves_www_authenticate_header(self) -> None: + """Test that headers set on ApiError survive the exception handler.""" + # Arrange + test_client = TestClient(build_problem_app()) + + # Act + response = test_client.get("/auth-error") + + # Assert + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.json()["code"] == "NOT_AUTHENTICATED" + + def test_unhandled_exception_becomes_internal_error(self) -> None: + """Test that an unhandled exception returns 500 INTERNAL_ERROR.""" + # Arrange - Starlette re-raises after responding, so the client must not + test_client = TestClient(build_problem_app(), raise_server_exceptions=False) + + # Act + response = test_client.get("/unhandled-error") + + # Assert + assert response.status_code == 500 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.json() == { + "title": "Internal Server Error", + "status": 500, + "detail": "An unexpected error occurred.", + "code": "INTERNAL_ERROR", + } + + +class TestLoginProblemFormat: + """Test suite for the login validation special case on the real app.""" + + def test_login_validation_failure_returns_invalid_credentials( + self, client: TestClient, db: Session, api_v1_prefix: str + ) -> None: + """Test that a login validation failure stays 401 INVALID_CREDENTIALS.""" + # Act - missing password fails request validation before the route runs + response = client.post(f"{api_v1_prefix}/auth/login", data={"username": "test@example.com"}) + + # Assert + assert response.status_code == 401 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.headers["WWW-Authenticate"] == "Bearer" + body = response.json() + assert body["code"] == "INVALID_CREDENTIALS" + assert body["detail"] == "Incorrect email or password" + + +class TestDeveloperAuthCodes: + """Test suite for the auth error code split on developer (JWT) endpoints.""" + + def test_missing_token_returns_not_authenticated(self, client: TestClient, api_v1_prefix: str) -> None: + """Test that a missing Authorization header yields NOT_AUTHENTICATED.""" + # Act + response = client.get(f"{api_v1_prefix}/auth/me") + + # Assert + assert response.status_code == 401 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.headers["WWW-Authenticate"] == "Bearer" + assert response.json()["code"] == "NOT_AUTHENTICATED" + + def test_garbage_token_returns_invalid_token(self, client: TestClient, api_v1_prefix: str) -> None: + """Test that an unparseable bearer token yields INVALID_TOKEN.""" + # Act + response = client.get(f"{api_v1_prefix}/auth/me", headers={"Authorization": "Bearer garbage"}) + + # Assert + assert response.status_code == 401 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.headers["WWW-Authenticate"] == "Bearer" + assert response.json()["code"] == "INVALID_TOKEN" + + +class TestDerivedNotFoundCode: + """Test suite for not-found codes derived from CamelCase model names.""" + + def test_unknown_api_key_uses_word_boundary_code(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test that a missing ApiKey yields API_KEY_NOT_FOUND, not APIKEY_NOT_FOUND.""" + # Arrange + developer = DeveloperFactory() + headers = developer_auth_headers(developer.id) + + # Act + response = client.delete(f"{api_v1_prefix}/developer/api-keys/sk-{uuid4().hex}", headers=headers) + + # Assert + assert response.status_code == 404 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + assert response.json()["code"] == "API_KEY_NOT_FOUND" + + +class TestProblemOpenApi: + """Test suite for the OpenAPI schema patch in apply_problem_openapi.""" + + def test_problem_schema_replaces_validation_schemas(self, client: TestClient) -> None: + """Test that Problem is registered and FastAPI's validation schemas are gone.""" + # Act + schema = client.get("/openapi.json").json() + + # Assert + schemas = schema["components"]["schemas"] + assert "Problem" in schemas + assert "HTTPValidationError" not in schemas + assert "ValidationError" not in schemas + + def test_every_422_response_references_problem(self, client: TestClient) -> None: + """Test that all 422 responses use application/problem+json with the Problem ref.""" + # Act + schema = client.get("/openapi.json").json() + + # Assert + expected_content = {"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}} + checked = 0 + for path_item in schema["paths"].values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + response_422 = operation.get("responses", {}).get("422") + if response_422 is None: + continue + assert response_422["content"] == expected_content + checked += 1 + assert checked > 0 + + def test_explicit_user_responses_resolve_against_components(self, client: TestClient) -> None: + """Test that the documented 401/404 responses on GET /users/{user_id} resolve.""" + # Act + schema = client.get("/openapi.json").json() + + # Assert + operation = schema["paths"]["/api/v1/users/{user_id}"]["get"] + for status_code in ("401", "404"): + ref = operation["responses"][status_code]["content"]["application/problem+json"]["schema"]["$ref"] + assert ref == "#/components/schemas/Problem" + assert ref.rsplit("/", 1)[-1] in schema["components"]["schemas"] + + +class TestMigratedEndpointCodes: + """Test suite for error codes on endpoints migrated to ApiError.""" + + def test_async_sync_with_unsupported_parameters(self, client: TestClient, db: Session) -> None: + """Test that provider-specific flags in async mode return a coded 400.""" + # Arrange + user = UserFactory() + api_key = ApiKeyFactory() + + # Act + response = client.post( + f"/api/v1/providers/garmin/users/{user.id}/sync", + headers=api_key_headers(api_key.id), + params={"samples": "true"}, + ) + + # Assert + assert response.status_code == 400 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + body = response.json() + assert body["code"] == "UNSUPPORTED_SYNC_PARAMETERS" + assert "samples" in body["detail"] + + def test_garmin_backfill_retry_invalid_type(self, client: TestClient, db: Session) -> None: + """Test that an unknown backfill type returns a coded 400.""" + # Arrange + user = UserFactory() + api_key = ApiKeyFactory() + + # Act + response = client.post( + f"/api/v1/providers/garmin/users/{user.id}/backfill/not_a_type/retry", + headers=api_key_headers(api_key.id), + ) + + # Assert + assert response.status_code == 400 + assert response.headers["content-type"] == PROBLEM_CONTENT_TYPE + body = response.json() + assert body["code"] == "INVALID_BACKFILL_TYPE" + assert "not_a_type" in body["detail"] diff --git a/backend/tests/api/v1/test_oauth.py b/backend/tests/api/v1/test_oauth.py index dc71224ef..ce9a7b4c1 100644 --- a/backend/tests/api/v1/test_oauth.py +++ b/backend/tests/api/v1/test_oauth.py @@ -86,7 +86,7 @@ def test_authorize_missing_user_id(self, client: TestClient, db: Session) -> Non response = client.get("/api/v1/oauth/garmin/authorize") # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_authorize_invalid_user_id(self, client: TestClient, db: Session) -> None: """Test authorization with invalid user_id format.""" @@ -97,7 +97,7 @@ def test_authorize_invalid_user_id(self, client: TestClient, db: Session) -> Non ) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_authorize_invalid_provider(self, client: TestClient, db: Session) -> None: """Test authorization with non-existent provider.""" @@ -111,7 +111,7 @@ def test_authorize_invalid_provider(self, client: TestClient, db: Session) -> No ) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_authorize_non_oauth_provider(self, client: TestClient, db: Session) -> None: """Test authorization with provider that doesn't support OAuth.""" @@ -365,6 +365,9 @@ def test_update_live_sync_mode_non_configurable_provider( # Assert assert response.status_code == 400 + body = response.json() + assert body["code"] == "UNSUPPORTED_PROVIDER_OPERATION" + assert body["detail"] == "Provider 'garmin' does not support live sync mode configuration." def test_update_provider_response_structure( self, diff --git a/backend/tests/api/v1/test_sdk_token.py b/backend/tests/api/v1/test_sdk_token.py index 500884885..393f60d5d 100644 --- a/backend/tests/api/v1/test_sdk_token.py +++ b/backend/tests/api/v1/test_sdk_token.py @@ -73,29 +73,31 @@ def test_token_contains_correct_claims(self, client: TestClient, db: Session, ap assert "exp" in payload def test_create_token_missing_app_id(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: - """Missing app_id should return validation error.""" + """Missing app_id should return 400 MISSING_APP_CREDENTIALS.""" user = UserFactory() response = client.post( f"{api_v1_prefix}/users/{user.id}/token", json={"app_secret": "secret"}, ) - # FastAPI returns 422 for validation errors, but some configs return 400 - assert response.status_code in [400, 422] + assert response.status_code == 400 + assert response.json()["code"] == "MISSING_APP_CREDENTIALS" def test_create_token_missing_app_secret(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: - """Missing app_secret should return validation error.""" + """Missing app_secret should return 400 MISSING_APP_CREDENTIALS.""" user = UserFactory() response = client.post( f"{api_v1_prefix}/users/{user.id}/token", json={"app_id": "app_123"}, ) - assert response.status_code in [400, 422] + assert response.status_code == 400 + assert response.json()["code"] == "MISSING_APP_CREDENTIALS" def test_create_token_empty_body(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: - """Empty body should return validation error.""" + """No app credentials and no admin auth should return 400 MISSING_APP_CREDENTIALS.""" user = UserFactory() response = client.post( f"{api_v1_prefix}/users/{user.id}/token", json={}, ) - assert response.status_code in [400, 422] + assert response.status_code == 400 + assert response.json()["code"] == "MISSING_APP_CREDENTIALS" diff --git a/backend/tests/api/v1/test_sync_data.py b/backend/tests/api/v1/test_sync_data.py index 3b77cb349..c67bdb84f 100644 --- a/backend/tests/api/v1/test_sync_data.py +++ b/backend/tests/api/v1/test_sync_data.py @@ -183,7 +183,7 @@ def test_sync_invalid_provider(self, client: TestClient, db: Session) -> None: ) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_sync_provider_not_supporting_workouts( self, diff --git a/backend/tests/api/v1/test_user_invitation_code.py b/backend/tests/api/v1/test_user_invitation_code.py index a1cf9cf0f..3e6935a27 100644 --- a/backend/tests/api/v1/test_user_invitation_code.py +++ b/backend/tests/api/v1/test_user_invitation_code.py @@ -168,11 +168,11 @@ def test_redeem_validation_rejects_short_code(self, client: TestClient, db: Sess response = client.post(f"{api_v1_prefix}/invitation-code/redeem", json={"code": "ABC"}) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_redeem_validation_rejects_lowercase(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: # Act response = client.post(f"{api_v1_prefix}/invitation-code/redeem", json={"code": "abcdefgh"}) # Assert - assert response.status_code == 400 + assert response.status_code == 422 diff --git a/backend/tests/api/v1/test_users.py b/backend/tests/api/v1/test_users.py index 2229f527b..f326f4aa2 100644 --- a/backend/tests/api/v1/test_users.py +++ b/backend/tests/api/v1/test_users.py @@ -154,7 +154,7 @@ def test_get_user_invalid_uuid(self, client: TestClient, db: Session, api_v1_pre response = client.get(f"{api_v1_prefix}/users/not-a-uuid", headers=headers) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_get_user_unauthorized(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Test getting user fails without API key.""" @@ -251,7 +251,7 @@ def test_create_user_invalid_email(self, client: TestClient, db: Session, api_v1 response = client.post(f"{api_v1_prefix}/users", json=payload, headers=headers) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_create_user_name_too_long(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Test creating user with name exceeding max length.""" @@ -268,7 +268,7 @@ def test_create_user_name_too_long(self, client: TestClient, db: Session, api_v1 response = client.post(f"{api_v1_prefix}/users", json=payload, headers=headers) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_create_user_unauthorized(self, client: TestClient, api_v1_prefix: str) -> None: """Test creating user fails without API key.""" @@ -392,7 +392,7 @@ def test_update_user_invalid_email(self, client: TestClient, db: Session, api_v1 response = client.patch(f"{api_v1_prefix}/users/{user.id}", json=payload, headers=headers) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_update_user_unauthorized(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Test updating user fails without authentication.""" @@ -472,7 +472,7 @@ def test_delete_user_invalid_uuid(self, client: TestClient, db: Session, api_v1_ response = client.delete(f"{api_v1_prefix}/users/not-a-uuid", headers=headers) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_delete_user_unauthorized(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Test deleting user fails without authentication.""" diff --git a/backend/tests/api/v1/test_vendor_workouts.py b/backend/tests/api/v1/test_vendor_workouts.py index 6dda66f5c..30ddc5e25 100644 --- a/backend/tests/api/v1/test_vendor_workouts.py +++ b/backend/tests/api/v1/test_vendor_workouts.py @@ -264,7 +264,7 @@ def test_get_workout_detail_not_found( assert response.status_code == 404 def test_invalid_provider_returns_422(self, client: TestClient, db: Session) -> None: - """Test that invalid provider enum value returns 400.""" + """Test that invalid provider enum value returns 422.""" # Arrange user = UserFactory() api_key = ApiKeyFactory() @@ -276,7 +276,7 @@ def test_invalid_provider_returns_422(self, client: TestClient, db: Session) -> ) # Assert - assert response.status_code == 400 + assert response.status_code == 422 def test_provider_not_supporting_workouts(self, client: TestClient, db: Session) -> None: """Test provider that doesn't support workouts returns 501.""" diff --git a/backend/tests/api/v1/test_workouts.py b/backend/tests/api/v1/test_workouts.py index 9293b294f..45d71965e 100644 --- a/backend/tests/api/v1/test_workouts.py +++ b/backend/tests/api/v1/test_workouts.py @@ -315,7 +315,7 @@ def test_get_workouts_invalid_user_id(self, client: TestClient, db: Session) -> api_key = ApiKeyFactory() headers = api_key_headers(api_key.id) - # Act & Assert - Invalid UUID causes 400 Bad Request (or 422 depending on config, but here 400) + # Act & Assert - invalid UUID fails request validation now = datetime.now(timezone.utc) start_date = (now - timedelta(days=30)).isoformat() end_date = (now + timedelta(days=1)).isoformat() @@ -325,7 +325,7 @@ def test_get_workouts_invalid_user_id(self, client: TestClient, db: Session) -> headers=headers, params={"start_date": start_date, "end_date": end_date}, ) - assert response.status_code == 400 + assert response.status_code == 422 def test_get_workouts_nonexistent_user(self, client: TestClient, db: Session) -> None: """Test retrieving workouts for a user that doesn't exist.""" diff --git a/backend/tests/services/test_provider_settings_service.py b/backend/tests/services/test_provider_settings_service.py index ecc6271a7..79fc5e817 100644 --- a/backend/tests/services/test_provider_settings_service.py +++ b/backend/tests/services/test_provider_settings_service.py @@ -15,6 +15,7 @@ from app.schemas.enums import ProviderName from app.schemas.model_crud.data_priority import ProviderSettingUpdate from app.services.provider_settings_service import ProviderSettingsService +from app.utils.exceptions import UnsupportedProviderError class TestProviderSettingsServiceGetAllProviders: @@ -338,13 +339,13 @@ def test_update_live_sync_mode_configurable_provider(self, db: Session) -> None: assert suunto.live_sync_mode == LiveSyncMode.WEBHOOK def test_update_live_sync_mode_non_configurable_provider_raises(self, db: Session) -> None: - """Should raise ValueError when trying to set live_sync_mode on a non-configurable provider.""" + """Should raise UnsupportedProviderError when setting live_sync_mode on a non-configurable provider.""" # Garmin: webhook_stream=True, webhook_callback=True, rest_pull=False → live_sync_configurable=False service = ProviderSettingsService() update = ProviderSettingUpdate(live_sync_mode=LiveSyncMode.WEBHOOK) - with pytest.raises(ValueError, match="does not support live sync mode configuration"): + with pytest.raises(UnsupportedProviderError, match="does not support live sync mode configuration"): service.update_provider_setting(db, "garmin", update) def test_update_live_sync_mode_explicit_null_raises(self, db: Session) -> None: diff --git a/backend/tests/utils_tests/test_exceptions.py b/backend/tests/utils_tests/test_exceptions.py index 175253a7f..1d248cd03 100644 --- a/backend/tests/utils_tests/test_exceptions.py +++ b/backend/tests/utils_tests/test_exceptions.py @@ -16,9 +16,27 @@ ResourceNotFoundError, handle_exception, handle_exceptions, + not_found_code, ) +class TestNotFoundCode: + """Test suite for the not_found_code helper.""" + + def test_lowercase_entity_name(self) -> None: + """Test that a lowercase entity name maps to a simple code.""" + assert not_found_code("user") == "USER_NOT_FOUND" + + def test_camel_case_entity_name_keeps_word_boundaries(self) -> None: + """Test that CamelCase boundaries become underscores.""" + assert not_found_code("ApiKey") == "API_KEY_NOT_FOUND" + + def test_long_dynamic_entity_falls_back_to_resource(self) -> None: + """Test that an unbounded dynamic description gets the generic code.""" + entity = f"backfill window for user {uuid4()} between 2024-01-01 and 2024-02-01" + assert not_found_code(entity) == "RESOURCE_NOT_FOUND" + + class TestResourceNotFoundError: """Test suite for ResourceNotFoundError exception.""" @@ -84,6 +102,22 @@ def test_init_with_none_id(self) -> None: # Assert assert error.detail == "Session not found." + def test_code_derived_from_entity_name(self) -> None: + """Test that the code defaults to the derived not-found code.""" + # Act + error = ResourceNotFoundError("user") + + # Assert + assert error.code == "USER_NOT_FOUND" + + def test_explicit_code_overrides_derived_one(self) -> None: + """Test that an explicit code wins over the derived one.""" + # Act + error = ResourceNotFoundError("user", code="API_KEY_NOT_FOUND") + + # Assert + assert error.code == "API_KEY_NOT_FOUND" + class TestHandleExceptionWithSQLAIntegrityError: """Test suite for handle_exception with SQLAlchemy IntegrityError.""" @@ -215,65 +249,15 @@ def test_handle_attribute_error_capitalizes_entity(self) -> None: class TestHandleExceptionWithRequestValidationError: """Test suite for handle_exception with RequestValidationError.""" - def test_handle_request_validation_error_with_msg_and_ctx(self) -> None: - """Test handling RequestValidationError with message and context.""" - # Arrange - error_data = [ - { - "msg": "Invalid email format", - "ctx": {"error": "Must be a valid email address"}, - }, - ] - exc = RequestValidationError(error_data) - entity = "user" - - # Act - result = handle_exception(exc, entity) - - # Assert - assert isinstance(result, HTTPException) - assert result.status_code == 400 - assert "Invalid email format - Must be a valid email address" in result.detail - - def test_handle_request_validation_error_with_msg_only(self) -> None: - """Test handling RequestValidationError with message but no context.""" + def test_request_validation_error_is_reraised(self) -> None: + """RequestValidationError propagates to the app-level problem handler.""" # Arrange - error_data = [{"msg": "Field required"}] - exc = RequestValidationError(error_data) + exc = RequestValidationError([{"msg": "Field required"}]) entity = "user" - # Act - result = handle_exception(exc, entity) - - # Assert - assert result.status_code == 400 - assert result.detail == "Field required" - - def test_handle_request_validation_error_with_empty_ctx(self) -> None: - """Test handling RequestValidationError with empty context.""" - # Arrange - error_data = [{"msg": "Validation failed", "ctx": {}}] - exc = RequestValidationError(error_data) - entity = "device" - - # Act - result = handle_exception(exc, entity) - - # Assert - assert result.detail == "Validation failed" - - def test_handle_request_validation_error_with_none_ctx(self) -> None: - """Test handling RequestValidationError with None context.""" - # Arrange - error_data = [{"msg": "Type error", "ctx": None}] - exc = RequestValidationError(error_data) - entity = "session" - - # Act - result = handle_exception(exc, entity) - - # Assert - assert result.detail == "Type error" + # Act & Assert + with pytest.raises(RequestValidationError): + handle_exception(exc, entity) class TestHandleExceptionWithUnknownError: diff --git a/docs/api-reference/guides/apple-xml-import.mdx b/docs/api-reference/guides/apple-xml-import.mdx index 99538c369..6e550d09d 100644 --- a/docs/api-reference/guides/apple-xml-import.mdx +++ b/docs/api-reference/guides/apple-xml-import.mdx @@ -157,7 +157,10 @@ const presignedData = await response.json(); ```json 401 Unauthorized { - "detail": "Could not validate credentials" + "title": "Unauthorized", + "status": 401, + "detail": "Invalid or missing API key", + "code": "INVALID_API_KEY" } ``` diff --git a/docs/api-reference/guides/error-handling.mdx b/docs/api-reference/guides/error-handling.mdx index 088f35960..02653f7bb 100644 --- a/docs/api-reference/guides/error-handling.mdx +++ b/docs/api-reference/guides/error-handling.mdx @@ -1,27 +1,57 @@ --- title: "API Error Handling" -description: "Reference for Open Wearables API error codes, response formats, and resolution steps. Covers auth failures, validation errors, rate limits, and 404s." -keywords: ["open wearables api errors", "health api error handling", "wearable api error codes", "open wearables http errors"] +description: "Reference for Open Wearables API error responses. RFC 9457 problem details format, stable error codes, auth failures, validation errors, and 404s." +keywords: ["open wearables api errors", "health api error handling", "wearable api error codes", "rfc 9457 problem details", "open wearables http errors"] "og:title": "API Error Handling | Open Wearables Reference" -"og:description": "Open Wearables API error codes, formats, and resolution steps. Auth failures, validation, rate limits, and 404s." +"og:description": "Open Wearables API error responses in RFC 9457 problem details format. Stable error codes, auth failures, validation, and 404s." "og:url": "https://docs.openwearables.io/api-reference/guides/error-handling" "og:type": "article" "twitter:title": "API Error Handling | Open Wearables Reference" -"twitter:description": "Open Wearables API error codes, formats, and resolution steps. Auth failures, validation, rate limits, and 404s." +"twitter:description": "Open Wearables API error responses in RFC 9457 problem details format. Stable error codes, auth failures, validation, and 404s." "twitter:card": "summary_large_image" --- +## Error Response Format + +Every error response is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details object, served with the `application/problem+json` media type: + +```json +{ + "title": "Not Found", + "status": 404, + "detail": "User with ID: 123 not found.", + "code": "USER_NOT_FOUND" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `title` | `string` | The HTTP status phrase, e.g. `Not Found`. | +| `status` | `integer` | The HTTP status code, duplicated in the body. | +| `detail` | `string` | A human-readable explanation of this specific error. | +| `code` | `string` | A stable machine-readable error identifier, e.g. `USER_NOT_FOUND`. | +| `errors` | `array` | Only on validation errors (`422`). One entry per invalid field: `{field, message, type}`. | + + + Switch on `code` in client logic. It is the stable contract - `title` and `detail` are for humans and their wording may change. The RFC 9457 `type` member is omitted, which the RFC defines as equivalent to `about:blank`. + + +--- + ## Authentication Errors ### Missing Authentication ```json { - "detail": "Authentication required: provide JWT token or API key" + "title": "Unauthorized", + "status": 401, + "detail": "Authentication required: provide JWT token or API key", + "code": "NOT_AUTHENTICATED" } ``` -**Cause:** No API key provided in the request. +**Cause:** No credentials were sent at all. API-key endpoints return this when the API key header is missing; endpoints that require a developer JWT return it (with detail `Could not validate credentials`) when no bearer token is sent. If credentials were sent but rejected, the code is `INVALID_API_KEY` or `INVALID_TOKEN` instead. **Solution:** Include the API key header: ```bash @@ -36,7 +66,10 @@ keywords: ["open wearables api errors", "health api error handling", "wearable a ```json { - "detail": "Invalid or missing API key" + "title": "Unauthorized", + "status": 401, + "detail": "Invalid or missing API key", + "code": "INVALID_API_KEY" } ``` @@ -44,17 +77,35 @@ keywords: ["open wearables api errors", "health api error handling", "wearable a **Solution:** Verify your API key is correct and active in the developer portal. -### Protected Endpoint +### Invalid Token ```json { - "detail": "Could not validate credentials" + "title": "Unauthorized", + "status": 401, + "detail": "Could not validate credentials", + "code": "INVALID_TOKEN" } ``` -**Cause:** Attempting to access an endpoint that requires developer JWT authentication (not just an API key). +**Cause:** A bearer token was sent but rejected: it is expired or malformed, it is an SDK-scoped token used on a non-SDK endpoint, or the developer it belongs to no longer exists. + +**Solution:** Log in again via the developer portal to get a fresh JWT. SDK tokens are only valid on `/sdk/` endpoints. -**Solution:** Some operations (like deleting users) require logging in via the developer portal. Use JWT authentication for these endpoints. +### Login Failure + +```json +{ + "title": "Unauthorized", + "status": 401, + "detail": "Incorrect email or password", + "code": "INVALID_CREDENTIALS" +} +``` + +**Cause:** Wrong email or password on the developer login endpoint. Login failures always return `401` with this code, even when a field is missing. + +**Solution:** Check the credentials and retry. --- @@ -64,7 +115,10 @@ keywords: ["open wearables api errors", "health api error handling", "wearable a ```json { - "detail": "User not connected to garmin" + "title": "Unauthorized", + "status": 401, + "detail": "User not connected to garmin", + "code": "PROVIDER_NOT_CONNECTED" } ``` @@ -79,7 +133,10 @@ GET /api/v1/oauth/garmin/authorize?user_id={user_id} ```json { - "detail": "Token expired and no refresh token available for garmin" + "title": "Unauthorized", + "status": 401, + "detail": "Token expired and no refresh token available for garmin", + "code": "PROVIDER_AUTHORIZATION_EXPIRED" } ``` @@ -87,7 +144,10 @@ Or: ```json { - "detail": "Garmin authorization expired. Please re-authorize." + "title": "Unauthorized", + "status": 401, + "detail": "Garmin authorization expired. Please re-authorize.", + "code": "PROVIDER_AUTHORIZATION_EXPIRED" } ``` @@ -102,7 +162,10 @@ GET /api/v1/oauth/garmin/authorize?user_id={user_id} ```json { - "detail": "Provider 'apple' does not support OAuth" + "title": "Bad Request", + "status": 400, + "detail": "Provider 'apple' does not support OAuth", + "code": "UNSUPPORTED_PROVIDER_OPERATION" } ``` @@ -114,23 +177,45 @@ GET /api/v1/oauth/garmin/authorize?user_id={user_id} ```json { - "detail": "Input should be 'apple', 'garmin', 'polar' or 'suunto'" + "title": "Unprocessable Content", + "status": 422, + "detail": "Request validation failed.", + "code": "VALIDATION_ERROR", + "errors": [ + { + "field": "path.provider", + "message": "Input should be 'apple', 'samsung', 'google', 'garmin', 'polar', 'suunto', 'whoop', 'strava', 'oura', 'fitbit', 'ultrahuman', 'unknown' or 'internal'", + "type": "enum" + } + ] } ``` **Cause:** Provider name is invalid or has wrong case. -**Solution:** Use lowercase provider names: `garmin`, `polar`, `suunto`, `apple`. +**Solution:** Use lowercase provider names, e.g. `garmin`, not `Garmin`. The `message` field lists every accepted value. --- ## Validation Errors +Request validation failures return `422` with code `VALIDATION_ERROR` and an `errors` array listing every invalid field. `field` is the dot-joined location of the value (`body.email`, `query.start_time`, `path.user_id`), `message` is human-readable, and `type` is the validation rule that failed. + ### Missing Required Field ```json { - "detail": "Field required" + "title": "Unprocessable Content", + "status": 422, + "detail": "Request validation failed.", + "code": "VALIDATION_ERROR", + "errors": [ + { + "field": "query.start_time", + "message": "Field required", + "type": "missing" + } + ] } ``` @@ -145,7 +230,17 @@ GET /api/v1/oauth/garmin/authorize?user_id={user_id} ```json { - "detail": "Input should be a valid UUID, invalid character: expected an optional prefix of `urn:uuid:` followed by [0-9a-fA-F-], found `n` at 1" + "title": "Unprocessable Content", + "status": 422, + "detail": "Request validation failed.", + "code": "VALIDATION_ERROR", + "errors": [ + { + "field": "path.user_id", + "message": "Input should be a valid UUID, invalid character: expected an optional prefix of `urn:uuid:` followed by [0-9a-fA-F-], found `n` at 1", + "type": "uuid_parsing" + } + ] } ``` @@ -161,24 +256,46 @@ GET /api/v1/oauth/garmin/authorize?user_id={user_id} ```json { - "detail": "User with ID: {id} not found." + "title": "Not Found", + "status": 404, + "detail": "User with ID: 176be8de-8452-4eb7-a7ea-147fec925d9d not found.", + "code": "USER_NOT_FOUND" } ``` -**Cause:** The requested resource (user, connection, etc.) doesn't exist. +**Cause:** The requested resource (user, workout, etc.) doesn't exist. The code names the resource: `USER_NOT_FOUND`, `WORKOUT_NOT_FOUND`, `SLEEP_SESSION_NOT_FOUND`, and so on. **Solution:** Verify the ID is correct and the resource was created. --- +## Server Errors + +### Unexpected Error + +```json +{ + "title": "Internal Server Error", + "status": 500, + "detail": "An unexpected error occurred.", + "code": "INTERNAL_ERROR" +} +``` + +**Cause:** An unhandled error on the server. The response carries no internals; the full traceback is logged server-side. + +**Solution:** Retry the request. If it persists, report it with the request details. + +--- + ## HTTP Status Code Reference | Status | Meaning | Common Causes | |--------|---------|---------------| | 200 | Success | Request completed successfully | | 201 | Created | Resource created successfully | -| 400 | Bad Request | Invalid parameters, validation errors | -| 401 | Unauthorized | Missing or invalid authentication | +| 400 | Bad Request | Invalid parameters, unsupported provider operations | +| 401 | Unauthorized | Missing or invalid authentication, expired provider tokens | | 404 | Not Found | Resource doesn't exist | -| 422 | Unprocessable | Request body validation failed | -| 500 | Server Error | Internal error (please report) | +| 422 | Unprocessable | Request validation failed (`VALIDATION_ERROR`) | +| 500 | Server Error | Internal error (`INTERNAL_ERROR`, please report) | diff --git a/docs/api-reference/guides/sync-status-stream.mdx b/docs/api-reference/guides/sync-status-stream.mdx index 7b36cefd7..b1d0830e1 100644 --- a/docs/api-reference/guides/sync-status-stream.mdx +++ b/docs/api-reference/guides/sync-status-stream.mdx @@ -43,8 +43,15 @@ curl -H "X-API-Key: YOUR_API_KEY" \ **Error response shape** +Errors follow the [RFC 9457 problem details format](/api-reference/guides/error-handling) (`application/problem+json`): + ```json -{ "detail": "User not found" } +{ + "title": "Not Found", + "status": 404, + "detail": "User not found", + "code": "USER_NOT_FOUND" +} ``` @@ -143,12 +150,22 @@ data: {"event_id":"evt_01HZ...","run_id":"pull_garmin_user42_1730000000","provid ```json HTTP/1.1 401 Unauthorized -{ "detail": "Invalid authentication credentials" } +{ + "title": "Unauthorized", + "status": 401, + "detail": "Invalid or missing API key", + "code": "INVALID_API_KEY" +} ``` ```json HTTP/1.1 404 Not Found -{ "detail": "User not found" } +{ + "title": "Not Found", + "status": 404, + "detail": "User not found", + "code": "USER_NOT_FOUND" +} ``` ### Example: Node.js diff --git a/docs/api-reference/introduction.mdx b/docs/api-reference/introduction.mdx index a7450cccf..1a4d578cd 100644 --- a/docs/api-reference/introduction.mdx +++ b/docs/api-reference/introduction.mdx @@ -144,9 +144,14 @@ The API uses different response formats depending on the endpoint type: ### Error Response +Errors are returned as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details objects with the `application/problem+json` media type. The `code` field is a stable machine-readable identifier; validation errors (`422`) add an `errors` array. See the [error handling guide](/api-reference/guides/error-handling) for the full reference. + ```json { - "detail": "Error message describing what went wrong" + "title": "Not Found", + "status": 404, + "detail": "User with ID: 123 not found.", + "code": "USER_NOT_FOUND" } ``` @@ -158,6 +163,7 @@ The API uses different response formats depending on the endpoint type: - `400 Bad Request` - Invalid request parameters - `401 Unauthorized` - Authentication required or invalid - `404 Not Found` - Resource not found +- `422 Unprocessable Content` - Request validation failed - `500 Internal Server Error` - Server error ## Pagination diff --git a/docs/dev-guides/integration-guide.mdx b/docs/dev-guides/integration-guide.mdx index 3843e177b..70490fe5e 100644 --- a/docs/dev-guides/integration-guide.mdx +++ b/docs/dev-guides/integration-guide.mdx @@ -415,24 +415,54 @@ curl -X PUT "http://localhost:8000/api/v1/oauth/providers/suunto" \ } ``` -**400 — Provider does not support live sync mode configuration:** +**400 - Provider does not support live sync mode configuration:** Returned when `live_sync_mode` is included in the request body for a provider where `live_sync_configurable` is `false` (e.g. Garmin, which is webhook-only). ```json -{"detail": "Provider 'garmin' does not support live sync mode configuration"} +{ + "title": "Bad Request", + "status": 400, + "detail": "Provider 'garmin' does not support live sync mode configuration.", + "code": "UNSUPPORTED_PROVIDER_OPERATION" +} ``` -**400 — Invalid `live_sync_mode` value:** +**400 - Unknown provider:** + +Returned when the `{provider}` path segment is not a known provider name. This is a different error from the unsupported-operation case above. + +```json +{ + "title": "Bad Request", + "status": 400, + "detail": "Unknown provider: garmni", + "code": "INVALID_PROVIDER" +} +``` + +**422 - Invalid `live_sync_mode` value:** Returned when an invalid mode string or an explicit `null` is sent for `live_sync_mode`. ```json -{"detail": [{"loc": ["body", "live_sync_mode"], "msg": "Input should be 'pull' or 'webhook'"}]} +{ + "title": "Unprocessable Content", + "status": 422, + "detail": "Request validation failed.", + "code": "VALIDATION_ERROR", + "errors": [ + { + "field": "body.live_sync_mode", + "message": "Input should be 'pull' or 'webhook'", + "type": "literal_error" + } + ] +} ``` - Passing `"live_sync_mode": null` in the JSON body is invalid and returns 400. To leave the current mode unchanged, omit the field entirely from the request body. + Passing `"live_sync_mode": null` in the JSON body is invalid and returns 422. To leave the current mode unchanged, omit the field entirely from the request body. ### Get Authorization URL diff --git a/frontend/src/hooks/use-oauth-connect.ts b/frontend/src/hooks/use-oauth-connect.ts index 23a403aab..25a5d60e6 100644 --- a/frontend/src/hooks/use-oauth-connect.ts +++ b/frontend/src/hooks/use-oauth-connect.ts @@ -63,11 +63,9 @@ export function useOAuthConnect( ); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); + const problem = await response.json().catch(() => ({})); throw new Error( - errorData.detail || - errorData.message || - 'Failed to get authorization URL' + problem.detail || problem.title || 'Failed to get authorization URL' ); } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index f09bf9bef..886b2ab0e 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -3,6 +3,12 @@ import { ApiError } from '../errors/api-error'; import { getToken, clearSession } from '../auth/session'; import { ROUTES } from '../constants/routes'; +// Error responses use application/problem+json, success responses +// application/json. +function isJsonContentType(contentType: string | null): boolean { + return Boolean(contentType?.includes('json')); +} + interface RequestOptions extends RequestInit { timeout?: number; retries?: number; @@ -106,7 +112,7 @@ export const apiClient = { response.headers.get('content-length') === '0' ) { data = undefined; - } else if (contentType?.includes('application/json')) { + } else if (isJsonContentType(contentType)) { data = await response.json(); } else { data = await response.text(); @@ -168,7 +174,7 @@ export const apiClient = { let data: unknown; const contentType = response.headers.get('content-type'); - if (contentType?.includes('application/json')) { + if (isJsonContentType(contentType)) { data = await response.json(); } else { data = await response.text(); @@ -292,7 +298,7 @@ export const apiClient = { let data: unknown; const contentType = response.headers.get('content-type'); - if (contentType?.includes('application/json')) { + if (isJsonContentType(contentType)) { data = await response.json(); } else { data = await response.text(); diff --git a/frontend/src/lib/errors/api-error.ts b/frontend/src/lib/errors/api-error.ts index 43cadaea2..d0d7b8754 100644 --- a/frontend/src/lib/errors/api-error.ts +++ b/frontend/src/lib/errors/api-error.ts @@ -9,9 +9,18 @@ export type ApiErrorCode = | 'TIMEOUT' | 'UNKNOWN'; +export interface ApiValidationError { + field: string; + message: string; + type: string; +} + export class ApiError extends Error { code: ApiErrorCode; statusCode: number; + /** Machine-readable code from the backend, e.g. USER_NOT_FOUND. */ + serverCode?: string; + validationErrors?: ApiValidationError[]; details?: Record; constructor( @@ -38,18 +47,25 @@ export class ApiError extends Error { } static fromResponse(response: Response, data?: unknown): ApiError { - const dataObj = data as Record | undefined; + // Backend errors are RFC 9457 problem json: {title, status, detail, code} + // plus an `errors` list on 422 validation failures. + const problem = ( + typeof data === 'object' && data !== null ? data : {} + ) as Record; const message = - (dataObj?.message as string) || - (dataObj?.detail as string) || + (problem.detail as string) || + (problem.title as string) || response.statusText || 'An error occurred'; - const code = dataObj?.code as ApiErrorCode | undefined; - const details = - (dataObj?.details as Record) || - (dataObj?.validation_errors as Record); - return new ApiError(message, response.status, code, details); + const error = new ApiError(message, response.status); + if (typeof problem.code === 'string') { + error.serverCode = problem.code; + } + if (Array.isArray(problem.errors)) { + error.validationErrors = problem.errors as ApiValidationError[]; + } + return error; } static networkError(message: string = 'Network error occurred'): ApiError { @@ -68,8 +84,16 @@ export class ApiError extends Error { return 'You do not have permission to perform this action.'; case 'NOT_FOUND': return 'The requested resource was not found.'; - case 'VALIDATION_ERROR': - return this.message || 'Please check your input and try again.'; + case 'VALIDATION_ERROR': { + const fieldErrors = this.validationErrors + ?.map((item) => `${item.field}: ${item.message}`) + .join('; '); + return ( + fieldErrors || + this.message || + 'Please check your input and try again.' + ); + } case 'RATE_LIMITED': return 'Too many requests. Please wait a moment and try again.'; case 'SERVER_ERROR': diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 85d7e0821..ceba0e13e 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -333,7 +333,7 @@ declare module '@tanstack/react-router' { '/_authenticated': { id: '/_authenticated' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof AuthenticatedRouteImport parentRoute: typeof rootRouteImport }