From b1f0eb8921782fdb6481034b073a9345836f645e Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 27 Mar 2026 14:04:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E9=87=8D=E6=9E=84=20HTTP=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E4=B8=BA=20RFC7807=20?= =?UTF-8?q?=E6=A0=87=E5=87=86=E5=B9=B6=E4=BC=98=E5=8C=96=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/AGENTS.md | 10 + backend/src/app.py | 38 ++- backend/src/core/http/errors.py | 50 +++ backend/src/core/http/response.py | 7 + backend/src/core/runtime/cli.py | 7 +- backend/src/v1/agent/repository.py | 62 +++- backend/src/v1/agent/router.py | 94 +++++- backend/src/v1/agent/service.py | 163 ++++++++-- backend/src/v1/auth/gateway.py | 111 +++++-- backend/src/v1/auth/rate_limit.py | 16 +- backend/src/v1/automation_jobs/service.py | 21 +- backend/src/v1/friendships/service.py | 293 ++++++++++++++---- backend/src/v1/inbox_messages/service.py | 24 +- backend/src/v1/memories/service.py | 107 +++++-- backend/src/v1/schedule_items/service.py | 249 ++++++++++++--- backend/src/v1/todo/service.py | 162 ++++++++-- backend/src/v1/users/dependencies.py | 41 ++- backend/src/v1/users/service.py | 133 ++++++-- backend/tests/unit/test_response_envelope.py | 6 + .../tests/unit/v1/auth/test_auth_gateway.py | 4 +- .../unit/v1/automation_jobs/test_service.py | 8 +- .../v1/friendships/test_friendship_service.py | 14 +- .../unit/v1/inbox_messages/test_service.py | 8 +- .../unit/v1/schedule_items/test_service.py | 8 +- .../tests/unit/v1/users/test_dependencies.py | 4 +- 25 files changed, 1324 insertions(+), 316 deletions(-) create mode 100644 backend/src/core/http/errors.py diff --git a/backend/AGENTS.md b/backend/AGENTS.md index dc27fa8..eb0b94d 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -20,6 +20,16 @@ This file governs `backend/**` only. Keep it minimal, enforceable, and non-dupli - Use project logging (`core.logging`), never `print()` in runtime code. - HTTP errors must follow RFC 7807 (`application/problem+json`). +## HTTP Error Contract (Must) + +- Backend must construct error payload using RFC7807 fields plus stable extension fields: `code` and optional `params`. +- `code` must be machine-readable `UPPER_SNAKE_CASE`; do not use free-text `detail` as the only contract. +- Error code registry source of truth: `docs/protocols/common/http-error-codes.md`. +- Any create/modify/deprecate of error codes must update `docs/protocols/common/http-error-codes.md` in the same change. +- Keep response media type as `application/problem+json`. +- Long-term layering target: HTTP transport details stay in routers/global handlers; service/repository/dependencies should raise domain errors (`ApiProblemError` or domain-specific exceptions), not `HTTPException`. +- When refactoring existing code, prefer replacing `HTTPException` in service/repository/dependencies with `ApiProblemError` while preserving status/code semantics. + ## Configuration & Secrets - Read env only through `core.config.settings` (`Settings` / `config`). diff --git a/backend/src/app.py b/backend/src/app.py index 898db02..9342295 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,7 +1,7 @@ from __future__ import annotations from contextlib import asynccontextmanager -from typing import AsyncGenerator +from typing import Any, AsyncGenerator from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError @@ -11,6 +11,7 @@ from pydantic import BaseModel from starlette.exceptions import HTTPException as StarletteHTTPException from core.config.settings import config +from core.http.errors import ApiProblemError from core.http.response import build_problem_details from core.logging import configure_logging, get_logger, log_service_banner from services.base import close_registered_services, initialize_registered_services @@ -79,6 +80,19 @@ def _build_http_error_response( ) -> JSONResponse: instance = request.url.path detail_text = detail if isinstance(detail, str) else "Request failed" + error_code: str | None = None + error_params: dict[str, Any] | None = None + + if isinstance(detail, dict): + raw_detail = detail.get("detail") + raw_code = detail.get("code") + raw_params = detail.get("params") + if isinstance(raw_detail, str) and raw_detail.strip(): + detail_text = raw_detail + if isinstance(raw_code, str) and raw_code.strip(): + error_code = raw_code + if isinstance(raw_params, dict): + error_params = raw_params logger.warning( "HTTP error", status_code=status_code, @@ -91,6 +105,8 @@ def _build_http_error_response( status_code=status_code, detail=detail_text, instance=instance, + code=error_code, + params=error_params, ) return JSONResponse( status_code=status_code, @@ -170,3 +186,23 @@ async def unhandled_exception_handler( content=problem.model_dump(), media_type="application/problem+json", ) + + +@app.exception_handler(ApiProblemError) +async def api_problem_exception_handler( + request: Request, + exc: ApiProblemError, +) -> JSONResponse: + instance = request.url.path + problem = build_problem_details( + status_code=exc.status_code, + detail=exc.detail, + instance=instance, + code=exc.code, + params=exc.params, + ) + return JSONResponse( + status_code=exc.status_code, + content=problem.model_dump(), + media_type="application/problem+json", + ) diff --git a/backend/src/core/http/errors.py b/backend/src/core/http/errors.py new file mode 100644 index 0000000..cc331c3 --- /dev/null +++ b/backend/src/core/http/errors.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + + +class ApiProblemError(Exception): + def __init__( + self, + *, + status_code: int, + detail: str | dict[str, Any], + code: str | None = None, + params: dict[str, Any] | None = None, + ) -> None: + resolved_detail = detail + resolved_code = code + resolved_params = params + + if isinstance(detail, dict): + payload = detail + resolved_code = resolved_code or str( + payload.get("code") or "INTERNAL_ERROR" + ) + resolved_detail = str(payload.get("detail") or "Request failed") + raw_params = payload.get("params") + if resolved_params is None and isinstance(raw_params, dict): + resolved_params = raw_params + + if not isinstance(resolved_detail, str): + resolved_detail = str(resolved_detail) + if not resolved_code or not isinstance(resolved_code, str): + resolved_code = "INTERNAL_ERROR" + + super().__init__(resolved_detail) + self.status_code = status_code + self.code = resolved_code + self.detail = resolved_detail + self.params = resolved_params + + +def problem_payload( + *, + code: str, + detail: str, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = {"code": code, "detail": detail} + if params: + payload["params"] = params + return payload diff --git a/backend/src/core/http/response.py b/backend/src/core/http/response.py index 24a4c9c..636bc4b 100644 --- a/backend/src/core/http/response.py +++ b/backend/src/core/http/response.py @@ -1,6 +1,7 @@ from __future__ import annotations from http import HTTPStatus +from typing import Any from pydantic import BaseModel @@ -11,6 +12,8 @@ class ProblemDetails(BaseModel): status: int detail: str instance: str | None = None + code: str | None = None + params: dict[str, Any] | None = None def build_problem_details( @@ -20,6 +23,8 @@ def build_problem_details( type_value: str = "about:blank", title: str | None = None, instance: str | None = None, + code: str | None = None, + params: dict[str, Any] | None = None, ) -> ProblemDetails: resolved_title = title or HTTPStatus(status_code).phrase return ProblemDetails( @@ -28,4 +33,6 @@ def build_problem_details( status=status_code, detail=detail, instance=instance, + code=code, + params=params, ) diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py index f4917a8..cf6adb9 100644 --- a/backend/src/core/runtime/cli.py +++ b/backend/src/core/runtime/cli.py @@ -7,10 +7,9 @@ from pathlib import Path from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.interval import IntervalTrigger - from core.automation.scheduler import run_automation_scheduler_scan -from core.config.settings import config from core.config.initial.init_data import initialize_data +from core.config.settings import config from core.logging import get_logger logger = get_logger("core.runtime.cli") @@ -107,10 +106,6 @@ async def bootstrap() -> bool: async def run_automation_scheduler_forever() -> None: - if config.runtime.environment == "dev": - logger.info("Automation scheduler skipped in dev environment") - return - if not config.automation_scheduler.enabled: logger.info("Automation scheduler disabled by config") return diff --git a/backend/src/v1/agent/repository.py b/backend/src/v1/agent/repository.py index 2687319..23d54a7 100644 --- a/backend/src/v1/agent/repository.py +++ b/backend/src/v1/agent/repository.py @@ -5,10 +5,10 @@ from decimal import Decimal from typing import Protocol from uuid import UUID, uuid4 -from fastapi import HTTPException from sqlalchemy import Select, select from sqlalchemy.ext.asyncio import AsyncSession +from core.http.errors import ApiProblemError from models.agent_chat_message import AgentChatMessage from models.agent_chat_session import AgentChatSession from models.system_agents import SystemAgents @@ -39,14 +39,22 @@ class AgentRepository: try: session_uuid = UUID(session_id) except ValueError as exc: - raise HTTPException(status_code=422, detail="Invalid session_id") from exc + raise ApiProblemError( + status_code=422, + code="AGENT_SESSION_ID_INVALID", + detail="Invalid session_id", + ) from exc stmt = select(AgentChatSession.user_id).where( AgentChatSession.id == session_uuid ) owner_id = (await self._session.execute(stmt)).scalar_one_or_none() if owner_id is None: - raise HTTPException(status_code=404, detail="Session not found") + raise ApiProblemError( + status_code=404, + code="AGENT_SESSION_NOT_FOUND", + detail="Session not found", + ) return str(owner_id) async def create_session_for_user( @@ -55,14 +63,20 @@ class AgentRepository: try: user_uuid = UUID(user_id) except ValueError as exc: - raise HTTPException(status_code=422, detail="Invalid user_id") from exc + raise ApiProblemError( + status_code=422, + code="AGENT_USER_ID_INVALID", + detail="Invalid user_id", + ) from exc session_uuid = None if session_id is not None: try: session_uuid = UUID(session_id) except ValueError as exc: - raise HTTPException( - status_code=422, detail="Invalid session_id" + raise ApiProblemError( + status_code=422, + code="AGENT_SESSION_ID_INVALID", + detail="Invalid session_id", ) from exc session = AgentChatSession( @@ -84,7 +98,11 @@ class AgentRepository: try: session_uuid = UUID(session_id) except ValueError as exc: - raise HTTPException(status_code=422, detail="Invalid session_id") from exc + raise ApiProblemError( + status_code=422, + code="AGENT_SESSION_ID_INVALID", + detail="Invalid session_id", + ) from exc session = await self._session.get(AgentChatSession, session_uuid) if session is not None: await self._session.delete(session) @@ -103,7 +121,11 @@ class AgentRepository: try: session_uuid = UUID(session_id) except ValueError as exc: - raise HTTPException(status_code=422, detail="Invalid session_id") from exc + raise ApiProblemError( + status_code=422, + code="AGENT_SESSION_ID_INVALID", + detail="Invalid session_id", + ) from exc stmt = ( select(AgentChatSession) @@ -112,7 +134,11 @@ class AgentRepository: ) session_row = (await self._session.execute(stmt)).scalar_one_or_none() if session_row is None: - raise HTTPException(status_code=404, detail="Session not found") + raise ApiProblemError( + status_code=404, + code="AGENT_SESSION_NOT_FOUND", + detail="Session not found", + ) next_seq = int(session_row.message_count or 0) + 1 if not _has_title(session_row.title): @@ -144,7 +170,11 @@ class AgentRepository: try: session_uuid = UUID(session_id) except ValueError as exc: - raise HTTPException(status_code=422, detail="Invalid session_id") from exc + raise ApiProblemError( + status_code=422, + code="AGENT_SESSION_ID_INVALID", + detail="Invalid session_id", + ) from exc before_start = ( datetime.combine(before, time.min, tzinfo=timezone.utc) @@ -224,7 +254,11 @@ class AgentRepository: try: session_uuid = UUID(session_id) except ValueError as exc: - raise HTTPException(status_code=422, detail="Invalid session_id") from exc + raise ApiProblemError( + status_code=422, + code="AGENT_SESSION_ID_INVALID", + detail="Invalid session_id", + ) from exc safe_user_limit = max(int(user_message_limit), 1) message_stmt = ( @@ -265,7 +299,11 @@ class AgentRepository: try: user_uuid = UUID(user_id) except ValueError as exc: - raise HTTPException(status_code=422, detail="Invalid user_id") from exc + raise ApiProblemError( + status_code=422, + code="AGENT_USER_ID_INVALID", + detail="Invalid user_id", + ) from exc stmt = ( select(AgentChatSession.id) .where(AgentChatSession.user_id == user_uuid) diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index e14c534..db031a8 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -9,6 +9,7 @@ from datetime import date from typing import Annotated from ag_ui.core import RunAgentInput +from core.http.errors import problem_payload from core.agentscope.events import to_sse_event from core.agentscope.schemas.agui_input import ( parse_run_input, @@ -131,11 +132,17 @@ async def enqueue_run( try: request = parse_run_input(request.model_dump(by_alias=True, exclude_none=True)) except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc + raise HTTPException( + status_code=422, + detail=problem_payload(code="AGENT_RUN_INPUT_INVALID", detail=str(exc)), + ) from exc try: validate_run_request_messages_contract(request) except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc + raise HTTPException( + status_code=422, + detail=problem_payload(code="AGENT_RUN_MESSAGES_INVALID", detail=str(exc)), + ) from exc task = await service.enqueue_run( run_input=request, current_user=current_user, @@ -188,11 +195,23 @@ async def stream_events( if last_event_id is not None and ( len(last_event_id) > 32 or _LAST_EVENT_ID_RE.fullmatch(last_event_id) is None ): - raise HTTPException(status_code=422, detail="Invalid Last-Event-ID") + raise HTTPException( + status_code=422, + detail=problem_payload( + code="AGENT_INVALID_LAST_EVENT_ID", + detail="Invalid Last-Event-ID", + ), + ) sse_slot_acquired = await _acquire_sse_slot(user_id=str(current_user.id)) if not sse_slot_acquired: - raise HTTPException(status_code=429, detail="Too many SSE connections") + raise HTTPException( + status_code=429, + detail=problem_payload( + code="AGENT_SSE_CONNECTION_LIMIT", + detail="Too many SSE connections", + ), + ) async def _event_iter() -> AsyncIterator[str]: cursor = last_event_id @@ -283,9 +302,22 @@ async def upload_attachment( ) -> AttachmentUploadResponse: payload = await file.read() if not payload: - raise HTTPException(status_code=422, detail="Empty attachment") + raise HTTPException( + status_code=422, + detail=problem_payload( + code="AGENT_ATTACHMENT_EMPTY", + detail="Empty attachment", + ), + ) if len(payload) > _MAX_ATTACHMENT_UPLOAD_BYTES: - raise HTTPException(status_code=413, detail="Attachment too large") + raise HTTPException( + status_code=413, + detail=problem_payload( + code="AGENT_ATTACHMENT_TOO_LARGE", + detail="Attachment too large", + params={"maxBytes": _MAX_ATTACHMENT_UPLOAD_BYTES}, + ), + ) attachment = await service.upload_attachment( thread_id=thread_id, filename=file.filename, @@ -330,7 +362,13 @@ async def transcribe( temp_path: str | None = None try: if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES: - raise HTTPException(status_code=400, detail="Unsupported audio format") + raise HTTPException( + status_code=400, + detail=problem_payload( + code="AGENT_AUDIO_UNSUPPORTED_FORMAT", + detail="Unsupported audio format", + ), + ) content_length = request.headers.get("content-length") if content_length is not None: @@ -343,7 +381,14 @@ async def transcribe( and declared_length > _MAX_TRANSCRIBE_AUDIO_BYTES + _MULTIPART_OVERHEAD_BYTES ): - raise HTTPException(status_code=400, detail="Audio file too large") + raise HTTPException( + status_code=400, + detail=problem_payload( + code="AGENT_AUDIO_TOO_LARGE", + detail="Audio file too large", + params={"maxBytes": _MAX_TRANSCRIBE_AUDIO_BYTES}, + ), + ) with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file: temp_path = tmp_file.name @@ -356,16 +401,35 @@ async def transcribe( break total_bytes += len(chunk) if total_bytes > _MAX_TRANSCRIBE_AUDIO_BYTES: - raise HTTPException(status_code=400, detail="Audio file too large") + raise HTTPException( + status_code=400, + detail=problem_payload( + code="AGENT_AUDIO_TOO_LARGE", + detail="Audio file too large", + params={"maxBytes": _MAX_TRANSCRIBE_AUDIO_BYTES}, + ), + ) if len(header) < _WAV_HEADER_MIN_BYTES: required = _WAV_HEADER_MIN_BYTES - len(header) header.extend(chunk[:required]) tmp_file.write(chunk) if total_bytes == 0: - raise HTTPException(status_code=400, detail="Empty audio file") + raise HTTPException( + status_code=400, + detail=problem_payload( + code="AGENT_AUDIO_EMPTY", + detail="Empty audio file", + ), + ) if not _looks_like_wav_header(bytes(header)): - raise HTTPException(status_code=400, detail="Unsupported audio format") + raise HTTPException( + status_code=400, + detail=problem_payload( + code="AGENT_AUDIO_UNSUPPORTED_FORMAT", + detail="Unsupported audio format", + ), + ) transcript = await asr_service.transcribe_file( temp_path, audio.filename or "unknown" @@ -376,7 +440,13 @@ async def transcribe( except HTTPException: raise except RuntimeError: - raise HTTPException(status_code=502, detail="ASR service unavailable") + raise HTTPException( + status_code=502, + detail=problem_payload( + code="AGENT_ASR_UNAVAILABLE", + detail="ASR service unavailable", + ), + ) finally: await audio.close() if temp_path: diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index b117d3c..21d46ea 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -6,9 +6,9 @@ import hashlib from urllib.parse import urlparse from ag_ui.core import RunAgentInput -from fastapi import HTTPException from sqlalchemy.exc import IntegrityError +from core.http.errors import ApiProblemError, problem_payload from core.auth.models import CurrentUser from core.agentscope.caches.context_messages_cache import ( create_context_messages_cache, @@ -48,7 +48,10 @@ logger = get_logger(__name__) def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None: if owner_id != str(current_user.id): - raise HTTPException(status_code=403, detail="Forbidden") + raise ApiProblemError( + status_code=403, + detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"), + ) class AgentService: @@ -86,7 +89,10 @@ class AgentService: try: runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props) except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc + raise ApiProblemError( + status_code=422, + detail=problem_payload(code="AGENT_PAYLOAD_INVALID", detail=str(exc)), + ) from exc if runtime_config is None: from v1.agent.system_agents_config import ( @@ -97,7 +103,7 @@ class AgentService: try: owner = await self._repository.get_session_owner(session_id=thread_id) - except HTTPException as exc: + except ApiProblemError as exc: if exc.status_code != 404: raise try: @@ -249,9 +255,12 @@ class AgentService: mime_type = "application/octet-stream" if self._attachment_storage is None: - raise HTTPException( + raise ApiProblemError( status_code=503, - detail="Attachment storage unavailable", + detail=problem_payload( + code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE", + detail="Attachment storage unavailable", + ), ) try: @@ -268,12 +277,25 @@ class AgentService: ) ) if len(user_attachments) > MAX_ATTACHMENTS_PER_MESSAGE: - raise HTTPException(status_code=422, detail="Too many attachments") - except HTTPException: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_ATTACHMENTS_TOO_MANY", + detail="Too many attachments", + params={"max": MAX_ATTACHMENTS_PER_MESSAGE}, + ), + ) + except ApiProblemError: raise except Exception as exc: # noqa: BLE001 logger.warning("Failed to parse signed URL", url=url, error=str(exc)) - raise HTTPException(status_code=422, detail="Invalid signed image url") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_SIGNED_IMAGE_URL_INVALID", + detail="Invalid signed image url", + ), + ) metadata: AgentChatMessageMetadata | None = None if user_attachments: @@ -295,7 +317,7 @@ class AgentService: ) -> dict[str, str]: try: owner = await self._repository.get_session_owner(session_id=thread_id) - except HTTPException as exc: + except ApiProblemError as exc: if exc.status_code != 404: raise try: @@ -311,19 +333,48 @@ class AgentService: else: ensure_session_owner(owner_id=owner, current_user=current_user) if self._attachment_storage is None: - raise HTTPException( - status_code=503, detail="Attachment storage unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE", + detail="Attachment storage unavailable", + ), ) if not isinstance(content_type, str): - raise HTTPException(status_code=422, detail="Unsupported attachment type") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_ATTACHMENT_UNSUPPORTED_TYPE", + detail="Unsupported attachment type", + ), + ) mime_type = content_type.lower() if mime_type not in {"image/png", "image/jpeg", "image/webp"}: - raise HTTPException(status_code=422, detail="Unsupported attachment type") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_ATTACHMENT_UNSUPPORTED_TYPE", + detail="Unsupported attachment type", + ), + ) if not payload: - raise HTTPException(status_code=422, detail="Empty attachment") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_ATTACHMENT_EMPTY", + detail="Empty attachment", + ), + ) if len(payload) > MAX_ATTACHMENT_BYTES: - raise HTTPException(status_code=413, detail="Attachment too large") + raise ApiProblemError( + status_code=413, + detail=problem_payload( + code="AGENT_ATTACHMENT_TOO_LARGE", + detail="Attachment too large", + params={"maxBytes": MAX_ATTACHMENT_BYTES}, + ), + ) suffix = mime_to_suffix(mime_type) checksum = hashlib.sha1(payload).hexdigest()[:16] @@ -356,7 +407,13 @@ class AgentService: "thread_id": thread_id, }, ) - raise HTTPException(status_code=502, detail="Failed to upload attachment") + raise ApiProblemError( + status_code=502, + detail=problem_payload( + code="AGENT_ATTACHMENT_UPLOAD_FAILED", + detail="Failed to upload attachment", + ), + ) return { "bucket": bucket_name, @@ -373,19 +430,35 @@ class AgentService: current_user: CurrentUser, ) -> dict[str, str]: if self._attachment_storage is None: - raise HTTPException( - status_code=503, detail="Attachment storage unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE", + detail="Attachment storage unavailable", + ), ) normalized_bucket = bucket.strip() if normalized_bucket != config.storage.attachment.bucket: - raise HTTPException(status_code=422, detail="Invalid attachment bucket") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_ATTACHMENT_BUCKET_INVALID", + detail="Invalid attachment bucket", + ), + ) normalized_path = path.strip() expected_prefix = f"agent-inputs/{current_user.id}/" if not is_safe_attachment_path( normalized_path, expected_prefix=expected_prefix ): - raise HTTPException(status_code=422, detail="Invalid attachment path scope") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_ATTACHMENT_PATH_SCOPE_INVALID", + detail="Invalid attachment path scope", + ), + ) try: signed_url = await self._attachment_storage.create_signed_url( @@ -402,7 +475,13 @@ class AgentService: "user_id": str(current_user.id), }, ) - raise HTTPException(status_code=502, detail="Failed to generate signed URL") + raise ApiProblemError( + status_code=502, + detail=problem_payload( + code="AGENT_SIGNED_URL_GENERATION_FAILED", + detail="Failed to generate signed URL", + ), + ) return { "bucket": normalized_bucket, @@ -525,25 +604,51 @@ class AgentService: current_user: CurrentUser, ) -> tuple[str, str]: if self._attachment_storage is None: - raise HTTPException( - status_code=503, detail="Attachment storage unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE", + detail="Attachment storage unavailable", + ), ) parsed = urlparse(url) expected_host = urlparse(config.supabase.url).netloc if parsed.netloc != expected_host: - raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_HOST") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="INVALID_BINARY_URL_HOST", + detail="Invalid binary url host", + ), + ) try: bucket, path = self._attachment_storage.parse_signed_url(url) except Exception as exc: # noqa: BLE001 - raise HTTPException( - status_code=422, detail="Invalid signed image url" + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_SIGNED_IMAGE_URL_INVALID", + detail="Invalid signed image url", + ), ) from exc if bucket != config.storage.attachment.bucket: - raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_BUCKET") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="INVALID_BINARY_URL_BUCKET", + detail="Invalid binary url bucket", + ), + ) expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/" if not is_safe_attachment_path(path, expected_prefix=expected_prefix): - raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_PATH_SCOPE") + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="INVALID_BINARY_URL_PATH_SCOPE", + detail="Invalid binary url path scope", + ), + ) return bucket, path diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 56c2a09..42465b8 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -6,9 +6,9 @@ from typing import Any, cast from pydantic import ValidationError -from fastapi import HTTPException from supabase import AuthError +from core.http.errors import ApiProblemError from core.logging import get_logger from services.base.supabase import supabase_service from v1.auth.schemas import ( @@ -26,6 +26,15 @@ logger = get_logger("v1.auth.gateway") AUTH_UNAVAILABLE_DETAIL = "Auth service temporarily unavailable" +def _auth_error( + *, + status_code: int, + code: str, + detail: str, +) -> ApiProblemError: + return ApiProblemError(status_code=status_code, code=code, detail=detail) + + class SupabaseAuthGateway(AuthServiceGateway): def __init__(self) -> None: self._user_lookup_cache_ttl_seconds: int = 60 @@ -50,11 +59,16 @@ class SupabaseAuthGateway(AuthServiceGateway): except AuthError as exc: logger.warning("Send otp failed", error_type=type(exc).__name__) if _is_auth_upstream_unavailable(exc): - raise HTTPException( + raise _auth_error( status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", detail=AUTH_UNAVAILABLE_DETAIL, ) from exc - raise HTTPException(status_code=429, detail="Too many requests") from exc + raise _auth_error( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) from exc async def create_phone_session( self, request: PhoneSessionCreateRequest @@ -68,16 +82,23 @@ class SupabaseAuthGateway(AuthServiceGateway): try: verify_otp = cast(Any, client.auth.verify_otp) response = await asyncio.to_thread(verify_otp, payload) - return _map_auth_response(response, "Invalid verification code") + return _map_auth_response( + response, + "Invalid verification code", + "AUTH_VERIFICATION_CODE_INVALID", + ) except AuthError as exc: logger.warning("Create phone session failed", error_type=type(exc).__name__) if _is_auth_upstream_unavailable(exc): - raise HTTPException( + raise _auth_error( status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", detail=AUTH_UNAVAILABLE_DETAIL, ) from exc - raise HTTPException( - status_code=401, detail="Invalid verification code" + raise _auth_error( + status_code=401, + code="AUTH_VERIFICATION_CODE_INVALID", + detail="Invalid verification code", ) from exc async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: @@ -87,21 +108,32 @@ class SupabaseAuthGateway(AuthServiceGateway): client.auth.refresh_session, request.refresh_token, ) - return _map_auth_response(response, "Invalid refresh token") + return _map_auth_response( + response, + "Invalid refresh token", + "AUTH_REFRESH_TOKEN_INVALID", + ) except AuthError as exc: logger.warning("Refresh failed", error_type=type(exc).__name__) if _is_auth_upstream_unavailable(exc): - raise HTTPException( + raise _auth_error( status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", detail=AUTH_UNAVAILABLE_DETAIL, ) from exc - raise HTTPException( - status_code=401, detail="Invalid refresh token" + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", ) from exc async def delete_session(self, refresh_token: str | None) -> None: if not refresh_token: - raise HTTPException(status_code=401, detail="Missing refresh token") + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_MISSING", + detail="Missing refresh token", + ) client = self._get_client() try: response = await asyncio.to_thread( @@ -110,7 +142,11 @@ class SupabaseAuthGateway(AuthServiceGateway): ) session = getattr(response, "session", None) if session is None: - raise HTTPException(status_code=401, detail="Invalid refresh token") + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) await asyncio.to_thread( client.auth.set_session, str(session.access_token), @@ -120,28 +156,43 @@ class SupabaseAuthGateway(AuthServiceGateway): except AuthError as exc: logger.warning("Logout failed", error_type=type(exc).__name__) if _is_auth_upstream_unavailable(exc): - raise HTTPException( + raise _auth_error( status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", detail=AUTH_UNAVAILABLE_DETAIL, ) from exc - raise HTTPException( - status_code=401, detail="Invalid refresh token" + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", ) from exc async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse: normalized_phone = _normalize_phone(phone) if not normalized_phone: - raise HTTPException(status_code=404, detail="User not found") + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) await self._refresh_user_lookup_cache_if_needed() user = self._users_by_phone.get(normalized_phone) if user is None: - raise HTTPException(status_code=404, detail="User not found") + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) user_phone = _normalize_phone(getattr(user, "phone", "")) if not user_phone: - raise HTTPException(status_code=404, detail="User not found") + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) return UserByPhoneResponse( id=str(getattr(user, "id", "")), @@ -237,15 +288,25 @@ def _is_auth_upstream_unavailable(exc: AuthError) -> bool: return any(token in code or token in message for token in indicators) -def _map_auth_response(response: object, failure_message: str) -> SessionResponse: +def _map_auth_response( + response: object, failure_message: str, failure_code: str +) -> SessionResponse: session = getattr(response, "session", None) user = getattr(response, "user", None) if session is None or user is None: - raise HTTPException(status_code=401, detail=failure_message) + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) phone = _normalize_phone(getattr(user, "phone", None)) if not phone: - raise HTTPException(status_code=401, detail=failure_message) + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) try: auth_user = AuthUser(id=str(user.id), phone=str(phone)) @@ -254,7 +315,11 @@ def _map_auth_response(response: object, failure_message: str) -> SessionRespons "Auth response returned invalid phone format", error_type=type(exc).__name__, ) - raise HTTPException(status_code=401, detail=failure_message) from exc + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) from exc return SessionResponse( access_token=str(session.access_token), refresh_token=str(session.refresh_token), diff --git a/backend/src/v1/auth/rate_limit.py b/backend/src/v1/auth/rate_limit.py index 42e214b..e1183ba 100644 --- a/backend/src/v1/auth/rate_limit.py +++ b/backend/src/v1/auth/rate_limit.py @@ -4,7 +4,7 @@ import asyncio from collections import deque from time import monotonic -from fastapi import HTTPException +from core.http.errors import ApiProblemError from core.logging import get_logger from services.base.redis import get_or_init_redis_client @@ -39,7 +39,7 @@ async def enforce_rate_limit( window_seconds=window_seconds, ) return - except HTTPException: + except ApiProblemError: raise except Exception as exc: # noqa: BLE001 logger.warning( @@ -63,7 +63,11 @@ async def _enforce_rate_limit_with_redis( client = await get_or_init_redis_client() current = await client.eval(_REDIS_LIMIT_SCRIPT, 1, key, window_seconds) # type: ignore[await] if int(current) > limit: - raise HTTPException(status_code=429, detail="Too many requests") + raise ApiProblemError( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) async def _enforce_rate_limit_in_memory( @@ -81,7 +85,11 @@ async def _enforce_rate_limit_in_memory( while bucket and bucket[0] <= cutoff: bucket.popleft() if len(bucket) >= limit: - raise HTTPException(status_code=429, detail="Too many requests") + raise ApiProblemError( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) bucket.append(now) _CALL_COUNT += 1 if _CALL_COUNT % _CLEANUP_INTERVAL == 0: diff --git a/backend/src/v1/automation_jobs/service.py b/backend/src/v1/automation_jobs/service.py index 0500b34..38d2713 100644 --- a/backend/src/v1/automation_jobs/service.py +++ b/backend/src/v1/automation_jobs/service.py @@ -6,8 +6,9 @@ from typing import TYPE_CHECKING, Protocol from uuid import UUID from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from fastapi import HTTPException, status +from fastapi import status from schemas.enums import ScheduleType +from core.http.errors import ApiProblemError from schemas.domain.automation import ( AutomationJob as AutomationJobSchema, MessageContextConfig, @@ -32,26 +33,29 @@ if TYPE_CHECKING: logger = get_logger("v1.automation_jobs.service") -class AutomationJobLimitExceeded(HTTPException): +class AutomationJobLimitExceeded(ApiProblemError): def __init__(self) -> None: super().__init__( status_code=status.HTTP_400_BAD_REQUEST, + code="AUTOMATION_JOB_LIMIT_EXCEEDED", detail="Maximum of 3 user jobs allowed", ) -class SystemJobModificationForbidden(HTTPException): +class SystemJobModificationForbidden(ApiProblemError): def __init__(self) -> None: super().__init__( status_code=status.HTTP_403_FORBIDDEN, + code="AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN", detail="System job cannot be modified", ) -class AutomationJobNotFound(HTTPException): +class AutomationJobNotFound(ApiProblemError): def __init__(self) -> None: super().__init__( status_code=status.HTTP_404_NOT_FOUND, + code="AUTOMATION_JOB_NOT_FOUND", detail="Automation job not found", ) @@ -219,8 +223,9 @@ class AutomationJobsService: except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to create automation job", owner_id=str(owner_id)) - raise HTTPException( + raise ApiProblemError( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="AUTOMATION_JOB_STORE_UNAVAILABLE", detail="Automation job store unavailable", ) @@ -244,8 +249,9 @@ class AutomationJobsService: except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to update automation job", job_id=str(job_id)) - raise HTTPException( + raise ApiProblemError( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="AUTOMATION_JOB_STORE_UNAVAILABLE", detail="Automation job store unavailable", ) @@ -261,7 +267,8 @@ class AutomationJobsService: except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to delete automation job", job_id=str(job_id)) - raise HTTPException( + raise ApiProblemError( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + code="AUTOMATION_JOB_STORE_UNAVAILABLE", detail="Automation job store unavailable", ) diff --git a/backend/src/v1/friendships/service.py b/backend/src/v1/friendships/service.py index e038e70..8f7cbb1 100644 --- a/backend/src/v1/friendships/service.py +++ b/backend/src/v1/friendships/service.py @@ -4,11 +4,11 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Literal, cast from uuid import UUID -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService +from core.http.errors import ApiProblemError, problem_payload from core.logging import get_logger from models.friendships import Friendship from models.inbox_messages import InboxMessage @@ -60,8 +60,12 @@ class FriendshipService(BaseService): target_user_id = request.target_user_id if user_id == target_user_id: - raise HTTPException( - status_code=400, detail="Cannot send friend request to yourself" + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIEND_REQUEST_SELF_NOT_ALLOWED", + detail="Cannot send friend request to yourself", + ), ) existing = await self._repository.get_friendship_between_users( @@ -70,17 +74,28 @@ class FriendshipService(BaseService): if existing: match existing.status: case FriendshipStatus.ACCEPTED: - raise HTTPException( - status_code=400, detail="Already friends with this user" + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIEND_ALREADY_ACCEPTED", + detail="Already friends with this user", + ), ) case FriendshipStatus.BLOCKED: - raise HTTPException( + raise ApiProblemError( status_code=400, - detail="Cannot send friend request to blocked user", + detail=problem_payload( + code="FRIEND_REQUEST_BLOCKED", + detail="Cannot send friend request to blocked user", + ), ) case FriendshipStatus.PENDING: - raise HTTPException( - status_code=400, detail="Friend request already sent" + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIEND_REQUEST_ALREADY_SENT", + detail="Friend request already sent", + ), ) case _: # DECLINED, CANCELED - 允许重新发送 @@ -91,8 +106,12 @@ class FriendshipService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) logger.info( @@ -113,8 +132,12 @@ class FriendshipService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) logger.info( @@ -132,12 +155,22 @@ class FriendshipService(BaseService): try: friendship = await self._repository.get_friendship_by_id(friendship_id) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) if friendship is None: - raise HTTPException(status_code=404, detail="Friend request not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="FRIEND_REQUEST_NOT_FOUND", + detail="Friend request not found", + ), + ) recipient_id = ( friendship.user_low_id @@ -153,18 +186,35 @@ class FriendshipService(BaseService): "friendship_id": str(friendship_id), }, ) - raise HTTPException( - status_code=403, detail="Not authorized to accept this request" + raise ApiProblemError( + status_code=403, + detail=problem_payload( + code="FRIEND_REQUEST_FORBIDDEN", + detail="Not authorized to accept this request", + params={"action": "accept"}, + ), ) if friendship.status != FriendshipStatus.PENDING: - raise HTTPException(status_code=400, detail="Friend request is not pending") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIEND_REQUEST_NOT_PENDING", + detail="Friend request is not pending", + ), + ) inbox = await self._repository.get_pending_inbox_for_recipient( user_id, friendship_id ) if inbox is None: - raise HTTPException(status_code=404, detail="Inbox message not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="FRIEND_INBOX_MESSAGE_NOT_FOUND", + detail="Inbox message not found", + ), + ) friendship.status = FriendshipStatus.ACCEPTED inbox.status = InboxMessageStatus.ACCEPTED @@ -173,14 +223,22 @@ class FriendshipService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) sender_id = friendship.initiator_id if sender_id is None: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) logger.info( @@ -210,12 +268,22 @@ class FriendshipService(BaseService): try: friendship = await self._repository.get_friendship_by_id(friendship_id) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) if friendship is None: - raise HTTPException(status_code=404, detail="Friend request not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="FRIEND_REQUEST_NOT_FOUND", + detail="Friend request not found", + ), + ) recipient_id = ( friendship.user_low_id @@ -231,12 +299,23 @@ class FriendshipService(BaseService): "friendship_id": str(friendship_id), }, ) - raise HTTPException( - status_code=403, detail="Not authorized to decline this request" + raise ApiProblemError( + status_code=403, + detail=problem_payload( + code="FRIEND_REQUEST_FORBIDDEN", + detail="Not authorized to decline this request", + params={"action": "decline"}, + ), ) if friendship.status != FriendshipStatus.PENDING: - raise HTTPException(status_code=400, detail="Friend request is not pending") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIEND_REQUEST_NOT_PENDING", + detail="Friend request is not pending", + ), + ) inbox = await self._repository.get_pending_inbox_for_recipient( user_id, friendship_id @@ -250,14 +329,22 @@ class FriendshipService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) sender_id = friendship.initiator_id if sender_id is None: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) logger.info( @@ -287,12 +374,22 @@ class FriendshipService(BaseService): try: friendship = await self._repository.get_friendship_by_id(friendship_id) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) if friendship is None: - raise HTTPException(status_code=404, detail="Friend request not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="FRIEND_REQUEST_NOT_FOUND", + detail="Friend request not found", + ), + ) if friendship.initiator_id != user_id: logger.warning( @@ -302,12 +399,23 @@ class FriendshipService(BaseService): "friendship_id": str(friendship_id), }, ) - raise HTTPException( - status_code=403, detail="Not authorized to cancel this request" + raise ApiProblemError( + status_code=403, + detail=problem_payload( + code="FRIEND_REQUEST_FORBIDDEN", + detail="Not authorized to cancel this request", + params={"action": "cancel"}, + ), ) if friendship.status != FriendshipStatus.PENDING: - raise HTTPException(status_code=400, detail="Friend request is not pending") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIEND_REQUEST_NOT_PENDING", + detail="Friend request is not pending", + ), + ) inbox = await self._repository.get_pending_inbox_for_recipient( friendship.user_high_id, friendship_id @@ -321,15 +429,23 @@ class FriendshipService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) sender = await self._user_repository.get_by_user_id(user_id) recipient_id = friendship.user_high_id if recipient_id is None: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) recipient = await self._user_repository.get_by_user_id(recipient_id) @@ -359,8 +475,12 @@ class FriendshipService(BaseService): user_id, InboxMessageStatus.PENDING ) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) candidate_inbox = [ @@ -423,22 +543,43 @@ class FriendshipService(BaseService): try: friendship = await self._repository.get_friendship_by_id(friendship_id) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) if friendship is None: - raise HTTPException(status_code=404, detail="Friend request not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="FRIEND_REQUEST_NOT_FOUND", + detail="Friend request not found", + ), + ) # Determine sender and recipient based on current user # initiator_id is the sender initiator_id = friendship.initiator_id if initiator_id is None: - raise HTTPException(status_code=400, detail="Invalid friendship data") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIENDSHIP_DATA_INVALID", + detail="Invalid friendship data", + ), + ) if friendship.user_low_id != user_id and friendship.user_high_id != user_id: - raise HTTPException( - status_code=403, detail="Not authorized to view this request" + raise ApiProblemError( + status_code=403, + detail=problem_payload( + code="FRIEND_REQUEST_FORBIDDEN", + detail="Not authorized to view this request", + params={"action": "view"}, + ), ) sender = await self._user_repository.get_by_user_id(initiator_id) @@ -478,8 +619,12 @@ class FriendshipService(BaseService): try: outgoing = await self._repository.get_outgoing_requests(user_id) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) if not outgoing: @@ -515,8 +660,12 @@ class FriendshipService(BaseService): try: friendships = await self._repository.get_friends_list(user_id) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) if not friendships: @@ -552,16 +701,30 @@ class FriendshipService(BaseService): user_id, friend_id ) except SQLAlchemyError: - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) if friendship is None: - raise HTTPException(status_code=404, detail="Friendship not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="FRIENDSHIP_NOT_FOUND", + detail="Friendship not found", + ), + ) if friendship.status != FriendshipStatus.ACCEPTED: - raise HTTPException( - status_code=400, detail="Can only remove accepted friendships" + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED", + detail="Can only remove accepted friendships", + ), ) friendship.deleted_at = datetime.now(timezone.utc) @@ -570,8 +733,12 @@ class FriendshipService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Friendship service unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="FRIENDSHIP_SERVICE_UNAVAILABLE", + detail="Friendship service unavailable", + ), ) logger.info( diff --git a/backend/src/v1/inbox_messages/service.py b/backend/src/v1/inbox_messages/service.py index 08e6a51..67b281b 100644 --- a/backend/src/v1/inbox_messages/service.py +++ b/backend/src/v1/inbox_messages/service.py @@ -5,11 +5,11 @@ from uuid import UUID import json -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService +from core.http.errors import ApiProblemError from core.logging import get_logger from models.inbox_messages import InboxMessage from v1.inbox_messages.repository import InboxMessageRepository @@ -25,6 +25,10 @@ if TYPE_CHECKING: logger = get_logger("v1.inbox_messages.service") +def _inbox_error(*, status_code: int, code: str, detail: str) -> ApiProblemError: + return ApiProblemError(status_code=status_code, code=code, detail=detail) + + class InboxMessageService(BaseService): _repository: InboxMessageRepository _session: AsyncSession @@ -48,8 +52,10 @@ class InboxMessageService(BaseService): messages = await self._repository.list_by_recipient(user_id, is_read) except SQLAlchemyError: logger.exception("Failed to list inbox messages", user_id=str(user_id)) - raise HTTPException( - status_code=503, detail="Inbox message store unavailable" + raise _inbox_error( + status_code=503, + code="INBOX_MESSAGE_STORE_UNAVAILABLE", + detail="Inbox message store unavailable", ) return [self._to_response(message) for message in messages] @@ -60,7 +66,11 @@ class InboxMessageService(BaseService): try: updated = await self._repository.mark_as_read(message_id, user_id) if updated is None: - raise HTTPException(status_code=404, detail="Inbox message not found") + raise _inbox_error( + status_code=404, + code="INBOX_MESSAGE_NOT_FOUND", + detail="Inbox message not found", + ) await self._session.commit() except SQLAlchemyError: await self._session.rollback() @@ -69,8 +79,10 @@ class InboxMessageService(BaseService): message_id=str(message_id), user_id=str(user_id), ) - raise HTTPException( - status_code=503, detail="Inbox message store unavailable" + raise _inbox_error( + status_code=503, + code="INBOX_MESSAGE_STORE_UNAVAILABLE", + detail="Inbox message store unavailable", ) return self._to_response(updated) diff --git a/backend/src/v1/memories/service.py b/backend/src/v1/memories/service.py index c597f4e..7aad54a 100644 --- a/backend/src/v1/memories/service.py +++ b/backend/src/v1/memories/service.py @@ -2,11 +2,11 @@ from __future__ import annotations from typing import TYPE_CHECKING -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService +from core.http.errors import ApiProblemError from core.logging import get_logger from models.memories import Memory from schemas.enums import MemoryType @@ -19,6 +19,15 @@ if TYPE_CHECKING: logger = get_logger("v1.memories.service") +def _memories_error( + *, + status_code: int, + code: str, + detail: str, +) -> ApiProblemError: + return ApiProblemError(status_code=status_code, code=code, detail=detail) + + class MemoriesService(BaseService): """Memories service handling user/work memory operations. @@ -52,7 +61,11 @@ class MemoriesService(BaseService): try: memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) if memory is None: return None @@ -65,7 +78,11 @@ class MemoriesService(BaseService): try: memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) if memory is None: return None @@ -83,7 +100,11 @@ class MemoriesService(BaseService): owner_id=user_id ) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) return { "user_memory": self._parse_user_content(user_memory) @@ -104,7 +125,11 @@ class MemoriesService(BaseService): try: memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) if memory is None: try: @@ -116,8 +141,10 @@ class MemoriesService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Memories service unavailable" + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", ) else: try: @@ -129,8 +156,10 @@ class MemoriesService(BaseService): await self._session.refresh(memory) except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Memories service unavailable" + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", ) logger.info( @@ -150,7 +179,11 @@ class MemoriesService(BaseService): try: memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) if memory is None: try: @@ -162,8 +195,10 @@ class MemoriesService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Memories service unavailable" + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", ) else: try: @@ -175,8 +210,10 @@ class MemoriesService(BaseService): await self._session.refresh(memory) except SQLAlchemyError: await self._session.rollback() - raise HTTPException( - status_code=503, detail="Memories service unavailable" + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", ) logger.info( @@ -194,10 +231,18 @@ class MemoriesService(BaseService): try: memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) if memory is None: - raise HTTPException(status_code=404, detail="User memory not found") + raise _memories_error( + status_code=404, + code="MEMORIES_USER_NOT_FOUND", + detail="User memory not found", + ) try: update_data: dict = {} @@ -218,7 +263,11 @@ class MemoriesService(BaseService): await self._session.refresh(memory) except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) logger.info( "user_memory_patched", @@ -235,10 +284,18 @@ class MemoriesService(BaseService): try: memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) if memory is None: - raise HTTPException(status_code=404, detail="Work memory not found") + raise _memories_error( + status_code=404, + code="MEMORIES_WORK_NOT_FOUND", + detail="Work memory not found", + ) try: update_data: dict = {} @@ -259,7 +316,11 @@ class MemoriesService(BaseService): await self._session.refresh(memory) except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) logger.info( "work_memory_patched", @@ -284,4 +345,8 @@ class MemoriesService(BaseService): memory_type=memory_type, ) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Memories service unavailable") + raise _memories_error( + status_code=503, + code="MEMORIES_SERVICE_UNAVAILABLE", + detail="Memories service unavailable", + ) diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 8e43c14..27a986a 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -4,11 +4,11 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Protocol, Literal from uuid import UUID -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService +from core.http.errors import ApiProblemError, problem_payload from core.logging import get_logger from models.inbox_messages import InboxMessage from models.schedule_items import ScheduleItem @@ -95,7 +95,13 @@ class ScheduleItemService(BaseService): normalized_end_at = self._to_utc(request.end_at) if normalized_end_at and normalized_end_at <= normalized_start_at: - raise HTTPException(status_code=400, detail="end_at must be after start_at") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_INVALID_TIME_RANGE", + detail="end_at must be after start_at", + ), + ) data = { "owner_id": user_id, @@ -125,8 +131,12 @@ class ScheduleItemService(BaseService): except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to create schedule item") - raise HTTPException( - status_code=503, detail="Schedule item store unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), ) return self._to_response(item) @@ -138,12 +148,22 @@ class ScheduleItemService(BaseService): item = await self._repository.get_by_id(item_id) except SQLAlchemyError: logger.exception("Failed to get schedule item", item_id=str(item_id)) - raise HTTPException( - status_code=503, detail="Schedule item store unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), ) if item is None: - raise HTTPException(status_code=404, detail="Schedule item not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_NOT_FOUND", + detail="Schedule item not found", + ), + ) is_owner = item.owner_id == user_id permission = 1 @@ -162,7 +182,13 @@ class ScheduleItemService(BaseService): try: existing = await self._repository.get_by_item_id(item_id, user_id) if existing is None: - raise HTTPException(status_code=404, detail="Schedule item not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_NOT_FOUND", + detail="Schedule item not found", + ), + ) # Build update dict from non-null fields update_data = request.model_dump(exclude_unset=True) @@ -187,12 +213,20 @@ class ScheduleItemService(BaseService): update_data["end_at"] = next_end if next_end is not None: if not isinstance(next_start, datetime): - raise HTTPException( - status_code=400, detail="start_at must include timezone" + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_START_AT_TIMEZONE_REQUIRED", + detail="start_at must include timezone", + ), ) if next_end <= next_start: - raise HTTPException( - status_code=400, detail="end_at must be after start_at" + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_INVALID_TIME_RANGE", + detail="end_at must be after start_at", + ), ) if not update_data: @@ -206,12 +240,22 @@ class ScheduleItemService(BaseService): except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to update schedule item", item_id=str(item_id)) - raise HTTPException( - status_code=503, detail="Schedule item store unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), ) if item is None: - raise HTTPException(status_code=404, detail="Schedule item not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_NOT_FOUND", + detail="Schedule item not found", + ), + ) return self._to_response(item) @@ -221,7 +265,13 @@ class ScheduleItemService(BaseService): try: existing = await self._repository.get_by_item_id(item_id, user_id) if existing is None: - raise HTTPException(status_code=404, detail="Schedule item not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_NOT_FOUND", + detail="Schedule item not found", + ), + ) title = existing.title await self._repository.delete_subscriptions_by_item_id(item_id) @@ -231,8 +281,12 @@ class ScheduleItemService(BaseService): except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to delete schedule item", item_id=str(item_id)) - raise HTTPException( - status_code=503, detail="Schedule item store unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), ) async def list_by_date_range( @@ -244,7 +298,13 @@ class ScheduleItemService(BaseService): normalized_end_at = self._to_utc_required(request.end_at) if normalized_end_at <= normalized_start_at: - raise HTTPException(status_code=400, detail="end_at must be after start_at") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_INVALID_TIME_RANGE", + detail="end_at must be after start_at", + ), + ) try: archived_count = await self._repository.archive_expired_subscribed_items( @@ -275,8 +335,12 @@ class ScheduleItemService(BaseService): except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to list schedule items") - raise HTTPException( - status_code=503, detail="Schedule item store unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), ) async def list_paginated( @@ -288,9 +352,23 @@ class ScheduleItemService(BaseService): ) -> tuple[list[ScheduleItemResponse], int]: user_id = self.require_user_id() if page < 1: - raise HTTPException(status_code=400, detail="page must be >= 1") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_PAGE_INVALID", + detail="page must be >= 1", + params={"min": 1, "page": page}, + ), + ) if page_size < 1 or page_size > 100: - raise HTTPException(status_code=400, detail="page_size must be 1..100") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_PAGE_SIZE_INVALID", + detail="page_size must be 1..100", + params={"min": 1, "max": 100, "page_size": page_size}, + ), + ) try: items, total = await self._repository.list_paginated( user_id, @@ -304,8 +382,12 @@ class ScheduleItemService(BaseService): page=page, page_size=page_size, ) - raise HTTPException( - status_code=503, detail="Schedule item store unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), ) return [self._to_response(item) for item in items], total @@ -317,23 +399,39 @@ class ScheduleItemService(BaseService): try: item = await self._repository.get_by_id(item_id) if item is None: - raise HTTPException(status_code=404, detail="Schedule item not found") + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_NOT_FOUND", + detail="Schedule item not found", + ), + ) inviter_permission = SubscriptionPermission.OWNER if item.owner_id != user_id: inviter_sub = await self._repository.get_subscription(item_id, user_id) if inviter_sub is None: - raise HTTPException( + raise ApiProblemError( status_code=403, - detail="You don't have permission to share this calendar", + detail=problem_payload( + code="SCHEDULE_ITEM_SHARE_FORBIDDEN", + detail="You don't have permission to share this calendar", + params={"reason": "not_subscriber"}, + ), ) inviter_permission = SubscriptionPermission(inviter_sub.permission) request_permission = request._permission_value() if request_permission > inviter_permission: - raise HTTPException( + raise ApiProblemError( status_code=403, - detail=f"You can only share with permissions up to {inviter_permission}", + detail=problem_payload( + code="SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED", + detail=( + f"You can only share with permissions up to {inviter_permission}" + ), + params={"max_permission": int(inviter_permission)}, + ), ) target_user = await self._auth_gateway.get_user_by_phone(request.phone) @@ -348,9 +446,12 @@ class ScheduleItemService(BaseService): item_id, recipient_id, SubscriptionStatus.PENDING ) else: - raise HTTPException( + raise ApiProblemError( status_code=400, - detail="User already has an active subscription to this calendar", + detail=problem_payload( + code="SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE", + detail="User already has an active subscription to this calendar", + ), ) else: await self._repository.create_subscription( @@ -368,14 +469,20 @@ class ScheduleItemService(BaseService): ) if existing_msg: if existing_msg.status == InboxMessageStatus.ACCEPTED: - raise HTTPException( + raise ApiProblemError( status_code=400, - detail="User already subscribed to this calendar", + detail=problem_payload( + code="SCHEDULE_ITEM_INVITE_ALREADY_SUBSCRIBED", + detail="User already subscribed to this calendar", + ), ) elif existing_msg.status == InboxMessageStatus.PENDING: - raise HTTPException( + raise ApiProblemError( status_code=400, - detail="User already has a pending invitation to this calendar", + detail=problem_payload( + code="SCHEDULE_ITEM_INVITE_ALREADY_PENDING", + detail="User already has a pending invitation to this calendar", + ), ) elif existing_msg.status == InboxMessageStatus.REJECTED: existing_msg.status = InboxMessageStatus.PENDING @@ -400,20 +507,30 @@ class ScheduleItemService(BaseService): self._session.add(message) await self._session.commit() - except HTTPException: + except ApiProblemError: raise except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to share schedule item", item_id=str(item_id)) - raise HTTPException( - status_code=503, detail="Schedule item store unavailable" + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), ) except ValueError: await self._session.rollback() logger.exception( "Auth lookup returned invalid user id", phone=request.phone ) - raise HTTPException(status_code=503, detail="Auth lookup unavailable") + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_AUTH_LOOKUP_UNAVAILABLE", + detail="Auth lookup unavailable", + ), + ) return ScheduleItemShareResponse(message="Calendar invitation sent") @@ -460,8 +577,12 @@ class ScheduleItemService(BaseService): item_id, user_id ) if inbox is None: - raise HTTPException( - status_code=404, detail="No pending invitation found" + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND", + detail="No pending invitation found", + ), ) content = inbox.content or {} @@ -487,12 +608,18 @@ class ScheduleItemService(BaseService): await self._session.commit() return {"message": "Subscription accepted"} - except HTTPException: + except ApiProblemError: raise except Exception: await self._session.rollback() logger.exception("Failed to accept subscription") - raise HTTPException(status_code=503, detail="Failed to accept subscription") + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_ACCEPT_SUBSCRIPTION_FAILED", + detail="Failed to accept subscription", + ), + ) async def reject_subscription(self, item_id: UUID) -> dict: user_id = self.require_user_id() @@ -502,8 +629,12 @@ class ScheduleItemService(BaseService): item_id, user_id ) if inbox is None: - raise HTTPException( - status_code=404, detail="No pending invitation found" + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND", + detail="No pending invitation found", + ), ) existing = await self._repository.get_subscription(item_id, user_id) @@ -516,12 +647,18 @@ class ScheduleItemService(BaseService): await self._session.commit() return {"message": "Subscription rejected"} - except HTTPException: + except ApiProblemError: raise except Exception: await self._session.rollback() logger.exception("Failed to reject subscription") - raise HTTPException(status_code=503, detail="Failed to reject subscription") + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_REJECT_SUBSCRIPTION_FAILED", + detail="Failed to reject subscription", + ), + ) async def _notify_subscribers( self, @@ -560,13 +697,23 @@ class ScheduleItemService(BaseService): if dt is None: return None if dt.tzinfo is None: - raise HTTPException( - status_code=400, detail="datetime must include timezone" + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_DATETIME_TIMEZONE_REQUIRED", + detail="datetime must include timezone", + ), ) return dt.astimezone(timezone.utc) def _to_utc_required(self, dt: datetime) -> datetime: normalized = self._to_utc(dt) if normalized is None: - raise HTTPException(status_code=400, detail="datetime is required") + raise ApiProblemError( + status_code=400, + detail=problem_payload( + code="SCHEDULE_ITEM_DATETIME_REQUIRED", + detail="datetime is required", + ), + ) return normalized diff --git a/backend/src/v1/todo/service.py b/backend/src/v1/todo/service.py index 4b6ed5d..34b64f5 100644 --- a/backend/src/v1/todo/service.py +++ b/backend/src/v1/todo/service.py @@ -4,11 +4,11 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService +from core.http.errors import ApiProblemError from core.logging import get_logger from models.todos import Todo from schemas.enums import TodoStatus @@ -29,6 +29,21 @@ if TYPE_CHECKING: logger = get_logger("v1.todo.service") +def _todo_error( + *, + status_code: int, + code: str, + detail: str, + params: dict[str, object] | None = None, +) -> ApiProblemError: + return ApiProblemError( + status_code=status_code, + code=code, + detail=detail, + params=params, + ) + + class TodoService(BaseService): """Todo service handling todo CRUD operations. @@ -84,7 +99,11 @@ class TodoService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) logger.info( "todo_created", @@ -102,10 +121,18 @@ class TodoService(BaseService): try: todo = await self._repository.get_by_id(todo_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) if todo is None: - raise HTTPException(status_code=404, detail="Todo not found") + raise _todo_error( + status_code=404, + code="TODO_NOT_FOUND", + detail="Todo not found", + ) if todo.owner_id != user_id: logger.warning( @@ -115,8 +142,11 @@ class TodoService(BaseService): "todo_id": str(todo_id), }, ) - raise HTTPException( - status_code=403, detail="Not authorized to access this todo" + raise _todo_error( + status_code=403, + code="TODO_ACCESS_FORBIDDEN", + detail="Not authorized to access this todo", + params={"action": "access"}, ) return await self._to_response(todo) @@ -127,10 +157,18 @@ class TodoService(BaseService): try: todo = await self._repository.get_by_id(todo_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) if todo is None: - raise HTTPException(status_code=404, detail="Todo not found") + raise _todo_error( + status_code=404, + code="TODO_NOT_FOUND", + detail="Todo not found", + ) if todo.owner_id != user_id: logger.warning( @@ -140,8 +178,11 @@ class TodoService(BaseService): "todo_id": str(todo_id), }, ) - raise HTTPException( - status_code=403, detail="Not authorized to update this todo" + raise _todo_error( + status_code=403, + code="TODO_ACCESS_FORBIDDEN", + detail="Not authorized to update this todo", + params={"action": "update"}, ) completed_at = None @@ -174,7 +215,11 @@ class TodoService(BaseService): await self._session.refresh(todo) except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) logger.info( "todo_updated", @@ -192,10 +237,18 @@ class TodoService(BaseService): try: todo = await self._repository.get_by_id(todo_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) if todo is None: - raise HTTPException(status_code=404, detail="Todo not found") + raise _todo_error( + status_code=404, + code="TODO_NOT_FOUND", + detail="Todo not found", + ) if todo.owner_id != user_id: logger.warning( @@ -205,8 +258,11 @@ class TodoService(BaseService): "todo_id": str(todo_id), }, ) - raise HTTPException( - status_code=403, detail="Not authorized to complete this todo" + raise _todo_error( + status_code=403, + code="TODO_ACCESS_FORBIDDEN", + detail="Not authorized to complete this todo", + params={"action": "complete"}, ) try: @@ -219,7 +275,11 @@ class TodoService(BaseService): await self._session.refresh(todo) except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) logger.info( "todo_completed", @@ -237,10 +297,18 @@ class TodoService(BaseService): try: todo = await self._repository.get_by_id(todo_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) if todo is None: - raise HTTPException(status_code=404, detail="Todo not found") + raise _todo_error( + status_code=404, + code="TODO_NOT_FOUND", + detail="Todo not found", + ) if todo.owner_id != user_id: logger.warning( @@ -250,8 +318,11 @@ class TodoService(BaseService): "todo_id": str(todo_id), }, ) - raise HTTPException( - status_code=403, detail="Not authorized to delete this todo" + raise _todo_error( + status_code=403, + code="TODO_ACCESS_FORBIDDEN", + detail="Not authorized to delete this todo", + params={"action": "delete"}, ) try: @@ -259,7 +330,11 @@ class TodoService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) logger.info( "todo_deleted", @@ -279,15 +354,26 @@ class TodoService(BaseService): try: for item in request.items: if item.id in seen_ids: - raise HTTPException(status_code=400, detail="Duplicate todo id") + raise _todo_error( + status_code=400, + code="TODO_REORDER_DUPLICATE_ID", + detail="Duplicate todo id", + ) seen_ids.add(item.id) todo = await self._repository.get_by_id(item.id) if todo is None: - raise HTTPException(status_code=404, detail="Todo not found") + raise _todo_error( + status_code=404, + code="TODO_NOT_FOUND", + detail="Todo not found", + ) if todo.owner_id != user_id: - raise HTTPException( - status_code=403, detail="Not authorized to reorder this todo" + raise _todo_error( + status_code=403, + code="TODO_ACCESS_FORBIDDEN", + detail="Not authorized to reorder this todo", + params={"action": "reorder"}, ) original_priorities.add(todo.priority) @@ -314,7 +400,11 @@ class TodoService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) async def list_todos( self, @@ -328,10 +418,20 @@ class TodoService(BaseService): try: status_enum = TodoStatus(status) except ValueError: - raise HTTPException(status_code=400, detail="Invalid status value") + raise _todo_error( + status_code=400, + code="TODO_STATUS_INVALID", + detail="Invalid status value", + params={"status": status}, + ) if priority is not None and (priority < 1 or priority > 4): - raise HTTPException(status_code=400, detail="Invalid priority value") + raise _todo_error( + status_code=400, + code="TODO_PRIORITY_INVALID", + detail="Invalid priority value", + params={"priority": priority, "min": 1, "max": 4}, + ) try: todos = await self._repository.list_by_owner( @@ -340,7 +440,11 @@ class TodoService(BaseService): priority=priority, ) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="Todo service unavailable") + raise _todo_error( + status_code=503, + code="TODO_SERVICE_UNAVAILABLE", + detail="Todo service unavailable", + ) return [await self._to_response(todo) for todo in todos] diff --git a/backend/src/v1/users/dependencies.py b/backend/src/v1/users/dependencies.py index aeba2e9..3d50d16 100644 --- a/backend/src/v1/users/dependencies.py +++ b/backend/src/v1/users/dependencies.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from uuid import UUID -from fastapi import Depends, Header, HTTPException +from fastapi import Depends, Header from sqlalchemy.ext.asyncio import AsyncSession from core.auth.jwt_verifier import ( @@ -14,6 +14,7 @@ from core.auth.jwt_verifier import ( from core.auth.models import CurrentUser from core.config.settings import config from core.db import get_db +from core.http.errors import ApiProblemError from core.logging import get_logger from services.base.supabase import supabase_service from v1.auth.gateway import SupabaseAuthGateway @@ -44,7 +45,11 @@ def get_jwt_verifier() -> JwtVerifier: ) if not issuer or not jwt_secret: logger.error("JWT validation failed: verifier config not configured") - raise HTTPException(status_code=503, detail="JWT verifier not configured") + raise ApiProblemError( + status_code=503, + code="JWT_VERIFIER_NOT_CONFIGURED", + detail="JWT verifier not configured", + ) _jwt_verifier = JwtVerifier( issuer=issuer, jwt_secret=jwt_secret, @@ -90,16 +95,24 @@ async def get_current_user( ) -> CurrentUser: if not authorization: logger.warning("JWT validation failed: missing authorization header") - raise HTTPException(status_code=401, detail="Unauthorized") + raise ApiProblemError( + status_code=401, + code="AUTH_UNAUTHORIZED", + detail="Unauthorized", + ) scheme, _, token = authorization.partition(" ") if scheme.lower() != "bearer" or not token: logger.warning("JWT validation failed: invalid authorization scheme") - raise HTTPException(status_code=401, detail="Unauthorized") + raise ApiProblemError( + status_code=401, + code="AUTH_UNAUTHORIZED", + detail="Unauthorized", + ) try: payload = get_jwt_verifier().verify(token) - except HTTPException: + except ApiProblemError: raise except TokenValidationError as exc: logger.warning( @@ -109,20 +122,32 @@ async def get_current_user( ) fallback_user = await _verify_user_with_supabase(token) if fallback_user is None: - raise HTTPException(status_code=401, detail="Unauthorized") from exc + raise ApiProblemError( + status_code=401, + code="AUTH_UNAUTHORIZED", + detail="Unauthorized", + ) from exc logger.info("JWT fallback validation succeeded", user_id=str(fallback_user.id)) return fallback_user subject = payload.get("sub") if not isinstance(subject, str) or not subject: logger.warning("JWT validation failed: missing or invalid subject claim") - raise HTTPException(status_code=401, detail="Unauthorized") + raise ApiProblemError( + status_code=401, + code="AUTH_UNAUTHORIZED", + detail="Unauthorized", + ) try: user_id = UUID(subject) except ValueError: logger.warning("JWT validation failed: invalid UUID in subject") - raise HTTPException(status_code=401, detail="Unauthorized") + raise ApiProblemError( + status_code=401, + code="AUTH_UNAUTHORIZED", + detail="Unauthorized", + ) logger.debug("JWT validation successful", user_id=str(user_id)) phone = payload.get("phone") if isinstance(payload.get("phone"), str) else None diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index 7b4b541..538a7af 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -4,7 +4,6 @@ import re from typing import TYPE_CHECKING, Protocol, cast from uuid import UUID -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.agentscope.caches.user_context_cache import ( @@ -13,6 +12,7 @@ from core.agentscope.caches.user_context_cache import ( from core.auth.models import CurrentUser from core.config.settings import config from core.db.base_service import BaseService +from core.http.errors import ApiProblemError from core.logging import get_logger from schemas.shared.user import UserContext, parse_profile_settings from services.base.supabase import supabase_service @@ -29,6 +29,21 @@ logger = get_logger("v1.users.service") _PHONE_QUERY_PATTERN = re.compile(r"^[+()\-\s\d]{4,32}$") +def _user_error( + *, + status_code: int, + code: str, + detail: str, + params: dict[str, object] | None = None, +) -> ApiProblemError: + return ApiProblemError( + status_code=status_code, + code=code, + detail=detail, + params=params, + ) + + def _mime_to_suffix(mime_type: str) -> str: """Convert MIME type to file suffix.""" mapping = { @@ -62,7 +77,7 @@ class AuthLookupAdapter: async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]: try: return await self._gateway.search_user_ids_by_phone(query, limit=limit) - except HTTPException: + except ApiProblemError: return [] @@ -102,10 +117,18 @@ class UserService(BaseService): try: user = await self._repository.get_by_user_id(user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="User store unavailable") + raise _user_error( + status_code=503, + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ) if user is None: - raise HTTPException(status_code=404, detail="User not found") + raise _user_error( + status_code=404, + code="USER_NOT_FOUND", + detail="User not found", + ) phone = self._current_user.phone if self._current_user else None return UserContext( id=str(user.id), @@ -122,10 +145,18 @@ class UserService(BaseService): try: profile = await self._repository.get_by_user_id(user_id) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="User store unavailable") + raise _user_error( + status_code=503, + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ) if profile is None: - raise HTTPException(status_code=404, detail="User not found") + raise _user_error( + status_code=404, + code="USER_NOT_FOUND", + detail="User not found", + ) return UserContext( id=str(profile.id), username=profile.username, @@ -145,17 +176,29 @@ class UserService(BaseService): } if not update_data: - raise HTTPException(status_code=400, detail="No fields to update") + raise _user_error( + status_code=400, + code="USER_UPDATE_FIELDS_EMPTY", + detail="No fields to update", + ) try: user = await self._repository.update_by_user_id(user_id, update_data) await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="User store unavailable") + raise _user_error( + status_code=503, + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ) if user is None: - raise HTTPException(status_code=404, detail="User not found") + raise _user_error( + status_code=404, + code="USER_NOT_FOUND", + detail="User not found", + ) try: await self._user_context_cache.invalidate_user(user_id=user_id) @@ -186,25 +229,39 @@ class UserService(BaseService): user_id = self.require_user_id() if not isinstance(content_type, str): - raise HTTPException(status_code=422, detail="Unsupported image type") + raise _user_error( + status_code=422, + code="USER_AVATAR_UNSUPPORTED_TYPE", + detail="Unsupported image type", + ) mime_type = content_type.lower() allowed_types = {"image/jpeg", "image/png", "image/webp"} if mime_type not in allowed_types: - raise HTTPException( + raise _user_error( status_code=422, + code="USER_AVATAR_UNSUPPORTED_TYPE", detail="Unsupported image type. Allowed: JPEG, PNG, WebP", + params={"allowed": ["image/jpeg", "image/png", "image/webp"]}, ) max_size_bytes = config.storage.avatar.max_size_mb * 1024 * 1024 if len(payload) > max_size_bytes: - raise HTTPException( + raise _user_error( status_code=413, - detail=f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB", + code="USER_AVATAR_TOO_LARGE", + detail=( + f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB" + ), + params={"max_size_mb": config.storage.avatar.max_size_mb}, ) if not payload: - raise HTTPException(status_code=422, detail="Empty image") + raise _user_error( + status_code=422, + code="USER_AVATAR_EMPTY", + detail="Empty image", + ) suffix = _mime_to_suffix(mime_type) path = f"{user_id}/avatar.{suffix}" @@ -227,7 +284,11 @@ class UserService(BaseService): "user_id": str(user_id), }, ) - raise HTTPException(status_code=502, detail="Failed to upload avatar") + raise _user_error( + status_code=502, + code="USER_AVATAR_UPLOAD_FAILED", + detail="Failed to upload avatar", + ) public_url = f"{config.supabase.public_url}/storage/v1/object/public/{bucket_name}/{stored_path}" @@ -237,10 +298,18 @@ class UserService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise HTTPException(status_code=503, detail="User store unavailable") + raise _user_error( + status_code=503, + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ) if user is None: - raise HTTPException(status_code=404, detail="User not found") + raise _user_error( + status_code=404, + code="USER_NOT_FOUND", + detail="User not found", + ) try: await self._user_context_cache.invalidate_user(user_id=user_id) @@ -257,10 +326,18 @@ class UserService(BaseService): try: user = await self._repository.get_by_username(username) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="User store unavailable") + raise _user_error( + status_code=503, + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ) if user is None: - raise HTTPException(status_code=404, detail="User not found") + raise _user_error( + status_code=404, + code="USER_NOT_FOUND", + detail="User not found", + ) return UserContext( id=str(user.id), username=user.username, @@ -288,7 +365,11 @@ class UserService(BaseService): async def _search_by_phone(self, phone: str) -> list[UserContext]: if self._auth_gateway is None: - raise HTTPException(status_code=503, detail="Auth lookup unavailable") + raise _user_error( + status_code=503, + code="USER_AUTH_LOOKUP_UNAVAILABLE", + detail="Auth lookup unavailable", + ) user_id_values = await self._auth_gateway.search_user_ids_by_phone( phone, limit=20 @@ -308,7 +389,11 @@ class UserService(BaseService): try: users_by_id = await self._repository.get_by_user_ids(user_ids) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="User store unavailable") + raise _user_error( + status_code=503, + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ) results: list[UserContext] = [] for user_id in user_ids: @@ -330,7 +415,11 @@ class UserService(BaseService): try: users = await self._repository.search_users(query, limit=20) except SQLAlchemyError: - raise HTTPException(status_code=503, detail="User store unavailable") + raise _user_error( + status_code=503, + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ) return [ UserContext( diff --git a/backend/tests/unit/test_response_envelope.py b/backend/tests/unit/test_response_envelope.py index 5e8ef20..fcf882b 100644 --- a/backend/tests/unit/test_response_envelope.py +++ b/backend/tests/unit/test_response_envelope.py @@ -12,6 +12,8 @@ def test_problem_details_defaults() -> None: assert result.status == 401 assert result.detail == "Unauthorized" assert result.instance is None + assert result.code is None + assert result.params is None def test_problem_details_overrides() -> None: @@ -21,6 +23,8 @@ def test_problem_details_overrides() -> None: type_value="https://example.com/problems/conflict", title="Conflict", instance="/api/mobile/auth/signup", + code="AUTH_CONFLICT", + params={"field": "email"}, ) assert result.type == "https://example.com/problems/conflict" @@ -28,3 +32,5 @@ def test_problem_details_overrides() -> None: assert result.status == 409 assert result.detail == "Conflict" assert result.instance == "/api/mobile/auth/signup" + assert result.code == "AUTH_CONFLICT" + assert result.params == {"field": "email"} diff --git a/backend/tests/unit/v1/auth/test_auth_gateway.py b/backend/tests/unit/v1/auth/test_auth_gateway.py index 8721baf..6076ecf 100644 --- a/backend/tests/unit/v1/auth/test_auth_gateway.py +++ b/backend/tests/unit/v1/auth/test_auth_gateway.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from fastapi import HTTPException +from core.http.errors import ApiProblemError from v1.auth.gateway import SupabaseAuthGateway from v1.auth.schemas import ( @@ -101,7 +101,7 @@ class TestSupabaseAuthGateway: return_value=SimpleNamespace(session=None, user=None) ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await sut.refresh_session(SessionRefreshRequest(refresh_token="bad")) assert exc_info.value.status_code == 401 diff --git a/backend/tests/unit/v1/automation_jobs/test_service.py b/backend/tests/unit/v1/automation_jobs/test_service.py index 08bde36..14d7ded 100644 --- a/backend/tests/unit/v1/automation_jobs/test_service.py +++ b/backend/tests/unit/v1/automation_jobs/test_service.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 import pytest -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError +from core.http.errors import ApiProblemError from models.automation_jobs import AutomationJobStatus, ScheduleType from v1.automation_jobs.service import ( @@ -203,7 +203,7 @@ class TestCreate: repository.count_user_jobs.return_value = 0 repository.create.side_effect = SQLAlchemyError("db down") - with pytest.raises(HTTPException) as exc: + with pytest.raises(ApiProblemError) as exc: await service.create(owner_id, data) assert exc.value.status_code == 503 @@ -316,7 +316,7 @@ class TestUpdate: repository.get_by_id.return_value = job repository.update.side_effect = SQLAlchemyError("db down") - with pytest.raises(HTTPException) as exc: + with pytest.raises(ApiProblemError) as exc: await service.update( job.id, owner_id, @@ -391,7 +391,7 @@ class TestDelete: repository.get_by_id.return_value = job repository.soft_delete.side_effect = SQLAlchemyError("db down") - with pytest.raises(HTTPException) as exc: + with pytest.raises(ApiProblemError) as exc: await service.delete(job.id, owner_id) assert exc.value.status_code == 503 diff --git a/backend/tests/unit/v1/friendships/test_friendship_service.py b/backend/tests/unit/v1/friendships/test_friendship_service.py index f73673c..66390f7 100644 --- a/backend/tests/unit/v1/friendships/test_friendship_service.py +++ b/backend/tests/unit/v1/friendships/test_friendship_service.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 import pytest -from fastapi import HTTPException +from core.http.errors import ApiProblemError from core.auth.models import CurrentUser from models.friendships import Friendship, FriendshipStatus @@ -293,7 +293,7 @@ class TestSendRequest: current_user=current_user, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.send_request( FriendRequestCreate(target_user_id=current_user.id, content=None) ) @@ -322,7 +322,7 @@ class TestSendRequest: current_user=current_user, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.send_request( FriendRequestCreate(target_user_id=USER_B, content=None) ) @@ -351,7 +351,7 @@ class TestSendRequest: current_user=current_user, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.send_request( FriendRequestCreate(target_user_id=USER_B, content=None) ) @@ -411,7 +411,7 @@ class TestAcceptRequest: current_user=current_user, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.accept_request(uuid4()) assert exc_info.value.status_code == 404 @@ -447,7 +447,7 @@ class TestAcceptRequest: current_user=current_user, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.accept_request(friendship.id) assert exc_info.value.status_code == 403 @@ -669,7 +669,7 @@ class TestRemoveFriend: current_user=current_user, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.remove_friend(uuid4()) assert exc_info.value.status_code == 404 diff --git a/backend/tests/unit/v1/inbox_messages/test_service.py b/backend/tests/unit/v1/inbox_messages/test_service.py index ad2f3cb..f303c2d 100644 --- a/backend/tests/unit/v1/inbox_messages/test_service.py +++ b/backend/tests/unit/v1/inbox_messages/test_service.py @@ -3,10 +3,10 @@ from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 import pytest -from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError from models.inbox_messages import ( InboxMessage, InboxMessageStatus as InboxMessageModelStatus, @@ -109,11 +109,12 @@ async def test_mark_as_read_raises_404_when_message_missing() -> None: current_user=CurrentUser(id=user_id), ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.mark_as_read(message_id) assert exc_info.value.status_code == 404 assert exc_info.value.detail == "Inbox message not found" + assert exc_info.value.code == "INBOX_MESSAGE_NOT_FOUND" session.commit.assert_not_awaited() @@ -133,9 +134,10 @@ async def test_mark_as_read_store_error_returns_503() -> None: current_user=CurrentUser(id=user_id), ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.mark_as_read(message_id) assert exc_info.value.status_code == 503 assert exc_info.value.detail == "Inbox message store unavailable" + assert exc_info.value.code == "INBOX_MESSAGE_STORE_UNAVAILABLE" session.rollback.assert_awaited_once() diff --git a/backend/tests/unit/v1/schedule_items/test_service.py b/backend/tests/unit/v1/schedule_items/test_service.py index 8f6d632..f5c4d3f 100644 --- a/backend/tests/unit/v1/schedule_items/test_service.py +++ b/backend/tests/unit/v1/schedule_items/test_service.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 import pytest -from fastapi import HTTPException +from core.http.errors import ApiProblemError from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser @@ -198,7 +198,7 @@ async def test_create_invalid_end_at( inbox_repository=mock_inbox_repository, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.create(request) assert exc_info.value.status_code == 400 @@ -234,7 +234,7 @@ async def test_get_by_id_not_found( inbox_repository=mock_inbox_repository, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.get_by_id(uuid4()) assert exc_info.value.status_code == 404 @@ -489,7 +489,7 @@ async def test_list_by_date_range_rolls_back_when_query_fails_after_archive( inbox_repository=mock_inbox_repository, ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ApiProblemError) as exc_info: await service.list_by_date_range( request=ScheduleItemListRequest( start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc), diff --git a/backend/tests/unit/v1/users/test_dependencies.py b/backend/tests/unit/v1/users/test_dependencies.py index 9a35012..67de20e 100644 --- a/backend/tests/unit/v1/users/test_dependencies.py +++ b/backend/tests/unit/v1/users/test_dependencies.py @@ -3,7 +3,7 @@ from __future__ import annotations from uuid import UUID import pytest -from fastapi import HTTPException +from core.http.errors import ApiProblemError from core.auth.jwt_verifier import TokenValidationError import v1.users.dependencies as deps @@ -49,7 +49,7 @@ async def test_get_current_user_raises_401_when_fallback_fails(monkeypatch) -> N monkeypatch.setattr(deps, "_verify_user_with_supabase", _fallback) - with pytest.raises(HTTPException) as exc: + with pytest.raises(ApiProblemError) as exc: await deps.get_current_user(authorization="Bearer invalid-token") assert exc.value.status_code == 401