feat(backend): 重构 HTTP 错误处理为 RFC7807 标准并优化多个 service

This commit is contained in:
qzl
2026-03-27 14:04:49 +08:00
parent 471488f5f7
commit b1f0eb8921
25 changed files with 1324 additions and 316 deletions
+10
View File
@@ -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. - Use project logging (`core.logging`), never `print()` in runtime code.
- HTTP errors must follow RFC 7807 (`application/problem+json`). - 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 ## Configuration & Secrets
- Read env only through `core.config.settings` (`Settings` / `config`). - Read env only through `core.config.settings` (`Settings` / `config`).
+37 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator from typing import Any, AsyncGenerator
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
@@ -11,6 +11,7 @@ from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from core.config.settings import config from core.config.settings import config
from core.http.errors import ApiProblemError
from core.http.response import build_problem_details from core.http.response import build_problem_details
from core.logging import configure_logging, get_logger, log_service_banner from core.logging import configure_logging, get_logger, log_service_banner
from services.base import close_registered_services, initialize_registered_services from services.base import close_registered_services, initialize_registered_services
@@ -79,6 +80,19 @@ def _build_http_error_response(
) -> JSONResponse: ) -> JSONResponse:
instance = request.url.path instance = request.url.path
detail_text = detail if isinstance(detail, str) else "Request failed" 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( logger.warning(
"HTTP error", "HTTP error",
status_code=status_code, status_code=status_code,
@@ -91,6 +105,8 @@ def _build_http_error_response(
status_code=status_code, status_code=status_code,
detail=detail_text, detail=detail_text,
instance=instance, instance=instance,
code=error_code,
params=error_params,
) )
return JSONResponse( return JSONResponse(
status_code=status_code, status_code=status_code,
@@ -170,3 +186,23 @@ async def unhandled_exception_handler(
content=problem.model_dump(), content=problem.model_dump(),
media_type="application/problem+json", 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",
)
+50
View File
@@ -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
+7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@@ -11,6 +12,8 @@ class ProblemDetails(BaseModel):
status: int status: int
detail: str detail: str
instance: str | None = None instance: str | None = None
code: str | None = None
params: dict[str, Any] | None = None
def build_problem_details( def build_problem_details(
@@ -20,6 +23,8 @@ def build_problem_details(
type_value: str = "about:blank", type_value: str = "about:blank",
title: str | None = None, title: str | None = None,
instance: str | None = None, instance: str | None = None,
code: str | None = None,
params: dict[str, Any] | None = None,
) -> ProblemDetails: ) -> ProblemDetails:
resolved_title = title or HTTPStatus(status_code).phrase resolved_title = title or HTTPStatus(status_code).phrase
return ProblemDetails( return ProblemDetails(
@@ -28,4 +33,6 @@ def build_problem_details(
status=status_code, status=status_code,
detail=detail, detail=detail,
instance=instance, instance=instance,
code=code,
params=params,
) )
+1 -6
View File
@@ -7,10 +7,9 @@ from pathlib import Path
from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from core.automation.scheduler import run_automation_scheduler_scan 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.initial.init_data import initialize_data
from core.config.settings import config
from core.logging import get_logger from core.logging import get_logger
logger = get_logger("core.runtime.cli") logger = get_logger("core.runtime.cli")
@@ -107,10 +106,6 @@ async def bootstrap() -> bool:
async def run_automation_scheduler_forever() -> None: 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: if not config.automation_scheduler.enabled:
logger.info("Automation scheduler disabled by config") logger.info("Automation scheduler disabled by config")
return return
+50 -12
View File
@@ -5,10 +5,10 @@ from decimal import Decimal
from typing import Protocol from typing import Protocol
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import HTTPException
from sqlalchemy import Select, select from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.http.errors import ApiProblemError
from models.agent_chat_message import AgentChatMessage from models.agent_chat_message import AgentChatMessage
from models.agent_chat_session import AgentChatSession from models.agent_chat_session import AgentChatSession
from models.system_agents import SystemAgents from models.system_agents import SystemAgents
@@ -39,14 +39,22 @@ class AgentRepository:
try: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: 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( stmt = select(AgentChatSession.user_id).where(
AgentChatSession.id == session_uuid AgentChatSession.id == session_uuid
) )
owner_id = (await self._session.execute(stmt)).scalar_one_or_none() owner_id = (await self._session.execute(stmt)).scalar_one_or_none()
if owner_id is 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) return str(owner_id)
async def create_session_for_user( async def create_session_for_user(
@@ -55,14 +63,20 @@ class AgentRepository:
try: try:
user_uuid = UUID(user_id) user_uuid = UUID(user_id)
except ValueError as exc: 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 session_uuid = None
if session_id is not None: if session_id is not None:
try: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise ApiProblemError(
status_code=422, detail="Invalid session_id" status_code=422,
code="AGENT_SESSION_ID_INVALID",
detail="Invalid session_id",
) from exc ) from exc
session = AgentChatSession( session = AgentChatSession(
@@ -84,7 +98,11 @@ class AgentRepository:
try: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: 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) session = await self._session.get(AgentChatSession, session_uuid)
if session is not None: if session is not None:
await self._session.delete(session) await self._session.delete(session)
@@ -103,7 +121,11 @@ class AgentRepository:
try: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: 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 = ( stmt = (
select(AgentChatSession) select(AgentChatSession)
@@ -112,7 +134,11 @@ class AgentRepository:
) )
session_row = (await self._session.execute(stmt)).scalar_one_or_none() session_row = (await self._session.execute(stmt)).scalar_one_or_none()
if session_row is 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 next_seq = int(session_row.message_count or 0) + 1
if not _has_title(session_row.title): if not _has_title(session_row.title):
@@ -144,7 +170,11 @@ class AgentRepository:
try: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: 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 = ( before_start = (
datetime.combine(before, time.min, tzinfo=timezone.utc) datetime.combine(before, time.min, tzinfo=timezone.utc)
@@ -224,7 +254,11 @@ class AgentRepository:
try: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: 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) safe_user_limit = max(int(user_message_limit), 1)
message_stmt = ( message_stmt = (
@@ -265,7 +299,11 @@ class AgentRepository:
try: try:
user_uuid = UUID(user_id) user_uuid = UUID(user_id)
except ValueError as exc: 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 = ( stmt = (
select(AgentChatSession.id) select(AgentChatSession.id)
.where(AgentChatSession.user_id == user_uuid) .where(AgentChatSession.user_id == user_uuid)
+82 -12
View File
@@ -9,6 +9,7 @@ from datetime import date
from typing import Annotated from typing import Annotated
from ag_ui.core import RunAgentInput from ag_ui.core import RunAgentInput
from core.http.errors import problem_payload
from core.agentscope.events import to_sse_event from core.agentscope.events import to_sse_event
from core.agentscope.schemas.agui_input import ( from core.agentscope.schemas.agui_input import (
parse_run_input, parse_run_input,
@@ -131,11 +132,17 @@ async def enqueue_run(
try: try:
request = parse_run_input(request.model_dump(by_alias=True, exclude_none=True)) request = parse_run_input(request.model_dump(by_alias=True, exclude_none=True))
except ValueError as exc: 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: try:
validate_run_request_messages_contract(request) validate_run_request_messages_contract(request)
except ValueError as exc: 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( task = await service.enqueue_run(
run_input=request, run_input=request,
current_user=current_user, current_user=current_user,
@@ -188,11 +195,23 @@ async def stream_events(
if last_event_id is not None and ( if last_event_id is not None and (
len(last_event_id) > 32 or _LAST_EVENT_ID_RE.fullmatch(last_event_id) is None 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)) sse_slot_acquired = await _acquire_sse_slot(user_id=str(current_user.id))
if not sse_slot_acquired: 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]: async def _event_iter() -> AsyncIterator[str]:
cursor = last_event_id cursor = last_event_id
@@ -283,9 +302,22 @@ async def upload_attachment(
) -> AttachmentUploadResponse: ) -> AttachmentUploadResponse:
payload = await file.read() payload = await file.read()
if not payload: 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: 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( attachment = await service.upload_attachment(
thread_id=thread_id, thread_id=thread_id,
filename=file.filename, filename=file.filename,
@@ -330,7 +362,13 @@ async def transcribe(
temp_path: str | None = None temp_path: str | None = None
try: try:
if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES: 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") content_length = request.headers.get("content-length")
if content_length is not None: if content_length is not None:
@@ -343,7 +381,14 @@ async def transcribe(
and declared_length and declared_length
> _MAX_TRANSCRIBE_AUDIO_BYTES + _MULTIPART_OVERHEAD_BYTES > _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: with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
temp_path = tmp_file.name temp_path = tmp_file.name
@@ -356,16 +401,35 @@ async def transcribe(
break break
total_bytes += len(chunk) total_bytes += len(chunk)
if total_bytes > _MAX_TRANSCRIBE_AUDIO_BYTES: 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: if len(header) < _WAV_HEADER_MIN_BYTES:
required = _WAV_HEADER_MIN_BYTES - len(header) required = _WAV_HEADER_MIN_BYTES - len(header)
header.extend(chunk[:required]) header.extend(chunk[:required])
tmp_file.write(chunk) tmp_file.write(chunk)
if total_bytes == 0: 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)): 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( transcript = await asr_service.transcribe_file(
temp_path, audio.filename or "unknown" temp_path, audio.filename or "unknown"
@@ -376,7 +440,13 @@ async def transcribe(
except HTTPException: except HTTPException:
raise raise
except RuntimeError: 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: finally:
await audio.close() await audio.close()
if temp_path: if temp_path:
+134 -29
View File
@@ -6,9 +6,9 @@ import hashlib
from urllib.parse import urlparse from urllib.parse import urlparse
from ag_ui.core import RunAgentInput from ag_ui.core import RunAgentInput
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from core.http.errors import ApiProblemError, problem_payload
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.agentscope.caches.context_messages_cache import ( from core.agentscope.caches.context_messages_cache import (
create_context_messages_cache, create_context_messages_cache,
@@ -48,7 +48,10 @@ logger = get_logger(__name__)
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None: def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
if owner_id != str(current_user.id): 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: class AgentService:
@@ -86,7 +89,10 @@ class AgentService:
try: try:
runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props) runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props)
except ValueError as exc: 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: if runtime_config is None:
from v1.agent.system_agents_config import ( from v1.agent.system_agents_config import (
@@ -97,7 +103,7 @@ class AgentService:
try: try:
owner = await self._repository.get_session_owner(session_id=thread_id) owner = await self._repository.get_session_owner(session_id=thread_id)
except HTTPException as exc: except ApiProblemError as exc:
if exc.status_code != 404: if exc.status_code != 404:
raise raise
try: try:
@@ -249,9 +255,12 @@ class AgentService:
mime_type = "application/octet-stream" mime_type = "application/octet-stream"
if self._attachment_storage is None: if self._attachment_storage is None:
raise HTTPException( raise ApiProblemError(
status_code=503, status_code=503,
detail="Attachment storage unavailable", detail=problem_payload(
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
detail="Attachment storage unavailable",
),
) )
try: try:
@@ -268,12 +277,25 @@ class AgentService:
) )
) )
if len(user_attachments) > MAX_ATTACHMENTS_PER_MESSAGE: if len(user_attachments) > MAX_ATTACHMENTS_PER_MESSAGE:
raise HTTPException(status_code=422, detail="Too many attachments") raise ApiProblemError(
except HTTPException: status_code=422,
detail=problem_payload(
code="AGENT_ATTACHMENTS_TOO_MANY",
detail="Too many attachments",
params={"max": MAX_ATTACHMENTS_PER_MESSAGE},
),
)
except ApiProblemError:
raise raise
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Failed to parse signed URL", url=url, error=str(exc)) 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 metadata: AgentChatMessageMetadata | None = None
if user_attachments: if user_attachments:
@@ -295,7 +317,7 @@ class AgentService:
) -> dict[str, str]: ) -> dict[str, str]:
try: try:
owner = await self._repository.get_session_owner(session_id=thread_id) owner = await self._repository.get_session_owner(session_id=thread_id)
except HTTPException as exc: except ApiProblemError as exc:
if exc.status_code != 404: if exc.status_code != 404:
raise raise
try: try:
@@ -311,19 +333,48 @@ class AgentService:
else: else:
ensure_session_owner(owner_id=owner, current_user=current_user) ensure_session_owner(owner_id=owner, current_user=current_user)
if self._attachment_storage is None: if self._attachment_storage is None:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Attachment storage unavailable" status_code=503,
detail=problem_payload(
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
detail="Attachment storage unavailable",
),
) )
if not isinstance(content_type, str): 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() mime_type = content_type.lower()
if mime_type not in {"image/png", "image/jpeg", "image/webp"}: 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: 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: 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) suffix = mime_to_suffix(mime_type)
checksum = hashlib.sha1(payload).hexdigest()[:16] checksum = hashlib.sha1(payload).hexdigest()[:16]
@@ -356,7 +407,13 @@ class AgentService:
"thread_id": thread_id, "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 { return {
"bucket": bucket_name, "bucket": bucket_name,
@@ -373,19 +430,35 @@ class AgentService:
current_user: CurrentUser, current_user: CurrentUser,
) -> dict[str, str]: ) -> dict[str, str]:
if self._attachment_storage is None: if self._attachment_storage is None:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Attachment storage unavailable" status_code=503,
detail=problem_payload(
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
detail="Attachment storage unavailable",
),
) )
normalized_bucket = bucket.strip() normalized_bucket = bucket.strip()
if normalized_bucket != config.storage.attachment.bucket: 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() normalized_path = path.strip()
expected_prefix = f"agent-inputs/{current_user.id}/" expected_prefix = f"agent-inputs/{current_user.id}/"
if not is_safe_attachment_path( if not is_safe_attachment_path(
normalized_path, expected_prefix=expected_prefix 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: try:
signed_url = await self._attachment_storage.create_signed_url( signed_url = await self._attachment_storage.create_signed_url(
@@ -402,7 +475,13 @@ class AgentService:
"user_id": str(current_user.id), "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 { return {
"bucket": normalized_bucket, "bucket": normalized_bucket,
@@ -525,25 +604,51 @@ class AgentService:
current_user: CurrentUser, current_user: CurrentUser,
) -> tuple[str, str]: ) -> tuple[str, str]:
if self._attachment_storage is None: if self._attachment_storage is None:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Attachment storage unavailable" status_code=503,
detail=problem_payload(
code="AGENT_ATTACHMENT_STORAGE_UNAVAILABLE",
detail="Attachment storage unavailable",
),
) )
parsed = urlparse(url) parsed = urlparse(url)
expected_host = urlparse(config.supabase.url).netloc expected_host = urlparse(config.supabase.url).netloc
if parsed.netloc != expected_host: 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: try:
bucket, path = self._attachment_storage.parse_signed_url(url) bucket, path = self._attachment_storage.parse_signed_url(url)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException( raise ApiProblemError(
status_code=422, detail="Invalid signed image url" status_code=422,
detail=problem_payload(
code="AGENT_SIGNED_IMAGE_URL_INVALID",
detail="Invalid signed image url",
),
) from exc ) from exc
if bucket != config.storage.attachment.bucket: 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/" expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
if not is_safe_attachment_path(path, expected_prefix=expected_prefix): 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 return bucket, path
+88 -23
View File
@@ -6,9 +6,9 @@ from typing import Any, cast
from pydantic import ValidationError from pydantic import ValidationError
from fastapi import HTTPException
from supabase import AuthError from supabase import AuthError
from core.http.errors import ApiProblemError
from core.logging import get_logger from core.logging import get_logger
from services.base.supabase import supabase_service from services.base.supabase import supabase_service
from v1.auth.schemas import ( from v1.auth.schemas import (
@@ -26,6 +26,15 @@ logger = get_logger("v1.auth.gateway")
AUTH_UNAVAILABLE_DETAIL = "Auth service temporarily unavailable" 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): class SupabaseAuthGateway(AuthServiceGateway):
def __init__(self) -> None: def __init__(self) -> None:
self._user_lookup_cache_ttl_seconds: int = 60 self._user_lookup_cache_ttl_seconds: int = 60
@@ -50,11 +59,16 @@ class SupabaseAuthGateway(AuthServiceGateway):
except AuthError as exc: except AuthError as exc:
logger.warning("Send otp failed", error_type=type(exc).__name__) logger.warning("Send otp failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc): if _is_auth_upstream_unavailable(exc):
raise HTTPException( raise _auth_error(
status_code=503, status_code=503,
code="AUTH_SERVICE_UNAVAILABLE",
detail=AUTH_UNAVAILABLE_DETAIL, detail=AUTH_UNAVAILABLE_DETAIL,
) from exc ) 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( async def create_phone_session(
self, request: PhoneSessionCreateRequest self, request: PhoneSessionCreateRequest
@@ -68,16 +82,23 @@ class SupabaseAuthGateway(AuthServiceGateway):
try: try:
verify_otp = cast(Any, client.auth.verify_otp) verify_otp = cast(Any, client.auth.verify_otp)
response = await asyncio.to_thread(verify_otp, payload) 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: except AuthError as exc:
logger.warning("Create phone session failed", error_type=type(exc).__name__) logger.warning("Create phone session failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc): if _is_auth_upstream_unavailable(exc):
raise HTTPException( raise _auth_error(
status_code=503, status_code=503,
code="AUTH_SERVICE_UNAVAILABLE",
detail=AUTH_UNAVAILABLE_DETAIL, detail=AUTH_UNAVAILABLE_DETAIL,
) from exc ) from exc
raise HTTPException( raise _auth_error(
status_code=401, detail="Invalid verification code" status_code=401,
code="AUTH_VERIFICATION_CODE_INVALID",
detail="Invalid verification code",
) from exc ) from exc
async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse:
@@ -87,21 +108,32 @@ class SupabaseAuthGateway(AuthServiceGateway):
client.auth.refresh_session, client.auth.refresh_session,
request.refresh_token, 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: except AuthError as exc:
logger.warning("Refresh failed", error_type=type(exc).__name__) logger.warning("Refresh failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc): if _is_auth_upstream_unavailable(exc):
raise HTTPException( raise _auth_error(
status_code=503, status_code=503,
code="AUTH_SERVICE_UNAVAILABLE",
detail=AUTH_UNAVAILABLE_DETAIL, detail=AUTH_UNAVAILABLE_DETAIL,
) from exc ) from exc
raise HTTPException( raise _auth_error(
status_code=401, detail="Invalid refresh token" status_code=401,
code="AUTH_REFRESH_TOKEN_INVALID",
detail="Invalid refresh token",
) from exc ) from exc
async def delete_session(self, refresh_token: str | None) -> None: async def delete_session(self, refresh_token: str | None) -> None:
if not refresh_token: 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() client = self._get_client()
try: try:
response = await asyncio.to_thread( response = await asyncio.to_thread(
@@ -110,7 +142,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
) )
session = getattr(response, "session", None) session = getattr(response, "session", None)
if session is 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( await asyncio.to_thread(
client.auth.set_session, client.auth.set_session,
str(session.access_token), str(session.access_token),
@@ -120,28 +156,43 @@ class SupabaseAuthGateway(AuthServiceGateway):
except AuthError as exc: except AuthError as exc:
logger.warning("Logout failed", error_type=type(exc).__name__) logger.warning("Logout failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc): if _is_auth_upstream_unavailable(exc):
raise HTTPException( raise _auth_error(
status_code=503, status_code=503,
code="AUTH_SERVICE_UNAVAILABLE",
detail=AUTH_UNAVAILABLE_DETAIL, detail=AUTH_UNAVAILABLE_DETAIL,
) from exc ) from exc
raise HTTPException( raise _auth_error(
status_code=401, detail="Invalid refresh token" status_code=401,
code="AUTH_REFRESH_TOKEN_INVALID",
detail="Invalid refresh token",
) from exc ) from exc
async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse: async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse:
normalized_phone = _normalize_phone(phone) normalized_phone = _normalize_phone(phone)
if not normalized_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() await self._refresh_user_lookup_cache_if_needed()
user = self._users_by_phone.get(normalized_phone) user = self._users_by_phone.get(normalized_phone)
if user is None: 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", "")) user_phone = _normalize_phone(getattr(user, "phone", ""))
if not 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( return UserByPhoneResponse(
id=str(getattr(user, "id", "")), 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) 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) session = getattr(response, "session", None)
user = getattr(response, "user", None) user = getattr(response, "user", None)
if session is None or user is 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)) phone = _normalize_phone(getattr(user, "phone", None))
if not phone: if not phone:
raise HTTPException(status_code=401, detail=failure_message) raise _auth_error(
status_code=401,
code=failure_code,
detail=failure_message,
)
try: try:
auth_user = AuthUser(id=str(user.id), phone=str(phone)) 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", "Auth response returned invalid phone format",
error_type=type(exc).__name__, 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( return SessionResponse(
access_token=str(session.access_token), access_token=str(session.access_token),
refresh_token=str(session.refresh_token), refresh_token=str(session.refresh_token),
+12 -4
View File
@@ -4,7 +4,7 @@ import asyncio
from collections import deque from collections import deque
from time import monotonic from time import monotonic
from fastapi import HTTPException from core.http.errors import ApiProblemError
from core.logging import get_logger from core.logging import get_logger
from services.base.redis import get_or_init_redis_client from services.base.redis import get_or_init_redis_client
@@ -39,7 +39,7 @@ async def enforce_rate_limit(
window_seconds=window_seconds, window_seconds=window_seconds,
) )
return return
except HTTPException: except ApiProblemError:
raise raise
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
@@ -63,7 +63,11 @@ async def _enforce_rate_limit_with_redis(
client = await get_or_init_redis_client() client = await get_or_init_redis_client()
current = await client.eval(_REDIS_LIMIT_SCRIPT, 1, key, window_seconds) # type: ignore[await] current = await client.eval(_REDIS_LIMIT_SCRIPT, 1, key, window_seconds) # type: ignore[await]
if int(current) > limit: 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( async def _enforce_rate_limit_in_memory(
@@ -81,7 +85,11 @@ async def _enforce_rate_limit_in_memory(
while bucket and bucket[0] <= cutoff: while bucket and bucket[0] <= cutoff:
bucket.popleft() bucket.popleft()
if len(bucket) >= limit: 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) bucket.append(now)
_CALL_COUNT += 1 _CALL_COUNT += 1
if _CALL_COUNT % _CLEANUP_INTERVAL == 0: if _CALL_COUNT % _CLEANUP_INTERVAL == 0:
+14 -7
View File
@@ -6,8 +6,9 @@ from typing import TYPE_CHECKING, Protocol
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException, status from fastapi import status
from schemas.enums import ScheduleType from schemas.enums import ScheduleType
from core.http.errors import ApiProblemError
from schemas.domain.automation import ( from schemas.domain.automation import (
AutomationJob as AutomationJobSchema, AutomationJob as AutomationJobSchema,
MessageContextConfig, MessageContextConfig,
@@ -32,26 +33,29 @@ if TYPE_CHECKING:
logger = get_logger("v1.automation_jobs.service") logger = get_logger("v1.automation_jobs.service")
class AutomationJobLimitExceeded(HTTPException): class AutomationJobLimitExceeded(ApiProblemError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
code="AUTOMATION_JOB_LIMIT_EXCEEDED",
detail="Maximum of 3 user jobs allowed", detail="Maximum of 3 user jobs allowed",
) )
class SystemJobModificationForbidden(HTTPException): class SystemJobModificationForbidden(ApiProblemError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
code="AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN",
detail="System job cannot be modified", detail="System job cannot be modified",
) )
class AutomationJobNotFound(HTTPException): class AutomationJobNotFound(ApiProblemError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
code="AUTOMATION_JOB_NOT_FOUND",
detail="Automation job not found", detail="Automation job not found",
) )
@@ -219,8 +223,9 @@ class AutomationJobsService:
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to create automation job", owner_id=str(owner_id)) logger.exception("Failed to create automation job", owner_id=str(owner_id))
raise HTTPException( raise ApiProblemError(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
code="AUTOMATION_JOB_STORE_UNAVAILABLE",
detail="Automation job store unavailable", detail="Automation job store unavailable",
) )
@@ -244,8 +249,9 @@ class AutomationJobsService:
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to update automation job", job_id=str(job_id)) logger.exception("Failed to update automation job", job_id=str(job_id))
raise HTTPException( raise ApiProblemError(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
code="AUTOMATION_JOB_STORE_UNAVAILABLE",
detail="Automation job store unavailable", detail="Automation job store unavailable",
) )
@@ -261,7 +267,8 @@ class AutomationJobsService:
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to delete automation job", job_id=str(job_id)) logger.exception("Failed to delete automation job", job_id=str(job_id))
raise HTTPException( raise ApiProblemError(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
code="AUTOMATION_JOB_STORE_UNAVAILABLE",
detail="Automation job store unavailable", detail="Automation job store unavailable",
) )
+230 -63
View File
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Literal, cast from typing import TYPE_CHECKING, Any, Literal, cast
from uuid import UUID from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.db.base_service import BaseService from core.db.base_service import BaseService
from core.http.errors import ApiProblemError, problem_payload
from core.logging import get_logger from core.logging import get_logger
from models.friendships import Friendship from models.friendships import Friendship
from models.inbox_messages import InboxMessage from models.inbox_messages import InboxMessage
@@ -60,8 +60,12 @@ class FriendshipService(BaseService):
target_user_id = request.target_user_id target_user_id = request.target_user_id
if user_id == target_user_id: if user_id == target_user_id:
raise HTTPException( raise ApiProblemError(
status_code=400, detail="Cannot send friend request to yourself" 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( existing = await self._repository.get_friendship_between_users(
@@ -70,17 +74,28 @@ class FriendshipService(BaseService):
if existing: if existing:
match existing.status: match existing.status:
case FriendshipStatus.ACCEPTED: case FriendshipStatus.ACCEPTED:
raise HTTPException( raise ApiProblemError(
status_code=400, detail="Already friends with this user" status_code=400,
detail=problem_payload(
code="FRIEND_ALREADY_ACCEPTED",
detail="Already friends with this user",
),
) )
case FriendshipStatus.BLOCKED: case FriendshipStatus.BLOCKED:
raise HTTPException( raise ApiProblemError(
status_code=400, 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: case FriendshipStatus.PENDING:
raise HTTPException( raise ApiProblemError(
status_code=400, detail="Friend request already sent" status_code=400,
detail=problem_payload(
code="FRIEND_REQUEST_ALREADY_SENT",
detail="Friend request already sent",
),
) )
case _: case _:
# DECLINED, CANCELED - 允许重新发送 # DECLINED, CANCELED - 允许重新发送
@@ -91,8 +106,12 @@ class FriendshipService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
logger.info( logger.info(
@@ -113,8 +132,12 @@ class FriendshipService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
logger.info( logger.info(
@@ -132,12 +155,22 @@ class FriendshipService(BaseService):
try: try:
friendship = await self._repository.get_friendship_by_id(friendship_id) friendship = await self._repository.get_friendship_by_id(friendship_id)
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
if friendship is None: 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 = ( recipient_id = (
friendship.user_low_id friendship.user_low_id
@@ -153,18 +186,35 @@ class FriendshipService(BaseService):
"friendship_id": str(friendship_id), "friendship_id": str(friendship_id),
}, },
) )
raise HTTPException( raise ApiProblemError(
status_code=403, detail="Not authorized to accept this request" 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: 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( inbox = await self._repository.get_pending_inbox_for_recipient(
user_id, friendship_id user_id, friendship_id
) )
if inbox is None: 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 friendship.status = FriendshipStatus.ACCEPTED
inbox.status = InboxMessageStatus.ACCEPTED inbox.status = InboxMessageStatus.ACCEPTED
@@ -173,14 +223,22 @@ class FriendshipService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
sender_id = friendship.initiator_id sender_id = friendship.initiator_id
if sender_id is None: if sender_id is None:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
logger.info( logger.info(
@@ -210,12 +268,22 @@ class FriendshipService(BaseService):
try: try:
friendship = await self._repository.get_friendship_by_id(friendship_id) friendship = await self._repository.get_friendship_by_id(friendship_id)
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
if friendship is None: 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 = ( recipient_id = (
friendship.user_low_id friendship.user_low_id
@@ -231,12 +299,23 @@ class FriendshipService(BaseService):
"friendship_id": str(friendship_id), "friendship_id": str(friendship_id),
}, },
) )
raise HTTPException( raise ApiProblemError(
status_code=403, detail="Not authorized to decline this request" 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: 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( inbox = await self._repository.get_pending_inbox_for_recipient(
user_id, friendship_id user_id, friendship_id
@@ -250,14 +329,22 @@ class FriendshipService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
sender_id = friendship.initiator_id sender_id = friendship.initiator_id
if sender_id is None: if sender_id is None:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
logger.info( logger.info(
@@ -287,12 +374,22 @@ class FriendshipService(BaseService):
try: try:
friendship = await self._repository.get_friendship_by_id(friendship_id) friendship = await self._repository.get_friendship_by_id(friendship_id)
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
if friendship is None: 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: if friendship.initiator_id != user_id:
logger.warning( logger.warning(
@@ -302,12 +399,23 @@ class FriendshipService(BaseService):
"friendship_id": str(friendship_id), "friendship_id": str(friendship_id),
}, },
) )
raise HTTPException( raise ApiProblemError(
status_code=403, detail="Not authorized to cancel this request" 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: 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( inbox = await self._repository.get_pending_inbox_for_recipient(
friendship.user_high_id, friendship_id friendship.user_high_id, friendship_id
@@ -321,15 +429,23 @@ class FriendshipService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" 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) sender = await self._user_repository.get_by_user_id(user_id)
recipient_id = friendship.user_high_id recipient_id = friendship.user_high_id
if recipient_id is None: if recipient_id is None:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" 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) recipient = await self._user_repository.get_by_user_id(recipient_id)
@@ -359,8 +475,12 @@ class FriendshipService(BaseService):
user_id, InboxMessageStatus.PENDING user_id, InboxMessageStatus.PENDING
) )
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
candidate_inbox = [ candidate_inbox = [
@@ -423,22 +543,43 @@ class FriendshipService(BaseService):
try: try:
friendship = await self._repository.get_friendship_by_id(friendship_id) friendship = await self._repository.get_friendship_by_id(friendship_id)
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
if friendship is None: 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 # Determine sender and recipient based on current user
# initiator_id is the sender # initiator_id is the sender
initiator_id = friendship.initiator_id initiator_id = friendship.initiator_id
if initiator_id is None: 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: if friendship.user_low_id != user_id and friendship.user_high_id != user_id:
raise HTTPException( raise ApiProblemError(
status_code=403, detail="Not authorized to view this request" 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) sender = await self._user_repository.get_by_user_id(initiator_id)
@@ -478,8 +619,12 @@ class FriendshipService(BaseService):
try: try:
outgoing = await self._repository.get_outgoing_requests(user_id) outgoing = await self._repository.get_outgoing_requests(user_id)
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
if not outgoing: if not outgoing:
@@ -515,8 +660,12 @@ class FriendshipService(BaseService):
try: try:
friendships = await self._repository.get_friends_list(user_id) friendships = await self._repository.get_friends_list(user_id)
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
if not friendships: if not friendships:
@@ -552,16 +701,30 @@ class FriendshipService(BaseService):
user_id, friend_id user_id, friend_id
) )
except SQLAlchemyError: except SQLAlchemyError:
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
if friendship is None: 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: if friendship.status != FriendshipStatus.ACCEPTED:
raise HTTPException( raise ApiProblemError(
status_code=400, detail="Can only remove accepted friendships" status_code=400,
detail=problem_payload(
code="FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED",
detail="Can only remove accepted friendships",
),
) )
friendship.deleted_at = datetime.now(timezone.utc) friendship.deleted_at = datetime.now(timezone.utc)
@@ -570,8 +733,12 @@ class FriendshipService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Friendship service unavailable" status_code=503,
detail=problem_payload(
code="FRIENDSHIP_SERVICE_UNAVAILABLE",
detail="Friendship service unavailable",
),
) )
logger.info( logger.info(
+18 -6
View File
@@ -5,11 +5,11 @@ from uuid import UUID
import json import json
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.db.base_service import BaseService from core.db.base_service import BaseService
from core.http.errors import ApiProblemError
from core.logging import get_logger from core.logging import get_logger
from models.inbox_messages import InboxMessage from models.inbox_messages import InboxMessage
from v1.inbox_messages.repository import InboxMessageRepository from v1.inbox_messages.repository import InboxMessageRepository
@@ -25,6 +25,10 @@ if TYPE_CHECKING:
logger = get_logger("v1.inbox_messages.service") 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): class InboxMessageService(BaseService):
_repository: InboxMessageRepository _repository: InboxMessageRepository
_session: AsyncSession _session: AsyncSession
@@ -48,8 +52,10 @@ class InboxMessageService(BaseService):
messages = await self._repository.list_by_recipient(user_id, is_read) messages = await self._repository.list_by_recipient(user_id, is_read)
except SQLAlchemyError: except SQLAlchemyError:
logger.exception("Failed to list inbox messages", user_id=str(user_id)) logger.exception("Failed to list inbox messages", user_id=str(user_id))
raise HTTPException( raise _inbox_error(
status_code=503, detail="Inbox message store unavailable" status_code=503,
code="INBOX_MESSAGE_STORE_UNAVAILABLE",
detail="Inbox message store unavailable",
) )
return [self._to_response(message) for message in messages] return [self._to_response(message) for message in messages]
@@ -60,7 +66,11 @@ class InboxMessageService(BaseService):
try: try:
updated = await self._repository.mark_as_read(message_id, user_id) updated = await self._repository.mark_as_read(message_id, user_id)
if updated is None: 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() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
@@ -69,8 +79,10 @@ class InboxMessageService(BaseService):
message_id=str(message_id), message_id=str(message_id),
user_id=str(user_id), user_id=str(user_id),
) )
raise HTTPException( raise _inbox_error(
status_code=503, detail="Inbox message store unavailable" status_code=503,
code="INBOX_MESSAGE_STORE_UNAVAILABLE",
detail="Inbox message store unavailable",
) )
return self._to_response(updated) return self._to_response(updated)
+86 -21
View File
@@ -2,11 +2,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.db.base_service import BaseService from core.db.base_service import BaseService
from core.http.errors import ApiProblemError
from core.logging import get_logger from core.logging import get_logger
from models.memories import Memory from models.memories import Memory
from schemas.enums import MemoryType from schemas.enums import MemoryType
@@ -19,6 +19,15 @@ if TYPE_CHECKING:
logger = get_logger("v1.memories.service") 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): class MemoriesService(BaseService):
"""Memories service handling user/work memory operations. """Memories service handling user/work memory operations.
@@ -52,7 +61,11 @@ class MemoriesService(BaseService):
try: try:
memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) memory = await self._repository.get_user_memory_for_owner(owner_id=user_id)
except SQLAlchemyError: 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: if memory is None:
return None return None
@@ -65,7 +78,11 @@ class MemoriesService(BaseService):
try: try:
memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) memory = await self._repository.get_work_memory_for_owner(owner_id=user_id)
except SQLAlchemyError: 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: if memory is None:
return None return None
@@ -83,7 +100,11 @@ class MemoriesService(BaseService):
owner_id=user_id owner_id=user_id
) )
except SQLAlchemyError: 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 { return {
"user_memory": self._parse_user_content(user_memory) "user_memory": self._parse_user_content(user_memory)
@@ -104,7 +125,11 @@ class MemoriesService(BaseService):
try: try:
memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) memory = await self._repository.get_user_memory_for_owner(owner_id=user_id)
except SQLAlchemyError: 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: if memory is None:
try: try:
@@ -116,8 +141,10 @@ class MemoriesService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise _memories_error(
status_code=503, detail="Memories service unavailable" status_code=503,
code="MEMORIES_SERVICE_UNAVAILABLE",
detail="Memories service unavailable",
) )
else: else:
try: try:
@@ -129,8 +156,10 @@ class MemoriesService(BaseService):
await self._session.refresh(memory) await self._session.refresh(memory)
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise _memories_error(
status_code=503, detail="Memories service unavailable" status_code=503,
code="MEMORIES_SERVICE_UNAVAILABLE",
detail="Memories service unavailable",
) )
logger.info( logger.info(
@@ -150,7 +179,11 @@ class MemoriesService(BaseService):
try: try:
memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) memory = await self._repository.get_work_memory_for_owner(owner_id=user_id)
except SQLAlchemyError: 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: if memory is None:
try: try:
@@ -162,8 +195,10 @@ class MemoriesService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise _memories_error(
status_code=503, detail="Memories service unavailable" status_code=503,
code="MEMORIES_SERVICE_UNAVAILABLE",
detail="Memories service unavailable",
) )
else: else:
try: try:
@@ -175,8 +210,10 @@ class MemoriesService(BaseService):
await self._session.refresh(memory) await self._session.refresh(memory)
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
raise HTTPException( raise _memories_error(
status_code=503, detail="Memories service unavailable" status_code=503,
code="MEMORIES_SERVICE_UNAVAILABLE",
detail="Memories service unavailable",
) )
logger.info( logger.info(
@@ -194,10 +231,18 @@ class MemoriesService(BaseService):
try: try:
memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) memory = await self._repository.get_user_memory_for_owner(owner_id=user_id)
except SQLAlchemyError: 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: 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: try:
update_data: dict = {} update_data: dict = {}
@@ -218,7 +263,11 @@ class MemoriesService(BaseService):
await self._session.refresh(memory) await self._session.refresh(memory)
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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( logger.info(
"user_memory_patched", "user_memory_patched",
@@ -235,10 +284,18 @@ class MemoriesService(BaseService):
try: try:
memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) memory = await self._repository.get_work_memory_for_owner(owner_id=user_id)
except SQLAlchemyError: 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: 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: try:
update_data: dict = {} update_data: dict = {}
@@ -259,7 +316,11 @@ class MemoriesService(BaseService):
await self._session.refresh(memory) await self._session.refresh(memory)
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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( logger.info(
"work_memory_patched", "work_memory_patched",
@@ -284,4 +345,8 @@ class MemoriesService(BaseService):
memory_type=memory_type, memory_type=memory_type,
) )
except SQLAlchemyError: 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",
)
+198 -51
View File
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol, Literal from typing import TYPE_CHECKING, Protocol, Literal
from uuid import UUID from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.db.base_service import BaseService from core.db.base_service import BaseService
from core.http.errors import ApiProblemError, problem_payload
from core.logging import get_logger from core.logging import get_logger
from models.inbox_messages import InboxMessage from models.inbox_messages import InboxMessage
from models.schedule_items import ScheduleItem from models.schedule_items import ScheduleItem
@@ -95,7 +95,13 @@ class ScheduleItemService(BaseService):
normalized_end_at = self._to_utc(request.end_at) normalized_end_at = self._to_utc(request.end_at)
if normalized_end_at and normalized_end_at <= normalized_start_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 = { data = {
"owner_id": user_id, "owner_id": user_id,
@@ -125,8 +131,12 @@ class ScheduleItemService(BaseService):
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to create schedule item") logger.exception("Failed to create schedule item")
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Schedule item store unavailable" status_code=503,
detail=problem_payload(
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
detail="Schedule item store unavailable",
),
) )
return self._to_response(item) return self._to_response(item)
@@ -138,12 +148,22 @@ class ScheduleItemService(BaseService):
item = await self._repository.get_by_id(item_id) item = await self._repository.get_by_id(item_id)
except SQLAlchemyError: except SQLAlchemyError:
logger.exception("Failed to get schedule item", item_id=str(item_id)) logger.exception("Failed to get schedule item", item_id=str(item_id))
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Schedule item store unavailable" status_code=503,
detail=problem_payload(
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
detail="Schedule item store unavailable",
),
) )
if item is None: 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 is_owner = item.owner_id == user_id
permission = 1 permission = 1
@@ -162,7 +182,13 @@ class ScheduleItemService(BaseService):
try: try:
existing = await self._repository.get_by_item_id(item_id, user_id) existing = await self._repository.get_by_item_id(item_id, user_id)
if existing is None: 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 # Build update dict from non-null fields
update_data = request.model_dump(exclude_unset=True) update_data = request.model_dump(exclude_unset=True)
@@ -187,12 +213,20 @@ class ScheduleItemService(BaseService):
update_data["end_at"] = next_end update_data["end_at"] = next_end
if next_end is not None: if next_end is not None:
if not isinstance(next_start, datetime): if not isinstance(next_start, datetime):
raise HTTPException( raise ApiProblemError(
status_code=400, detail="start_at must include timezone" status_code=400,
detail=problem_payload(
code="SCHEDULE_ITEM_START_AT_TIMEZONE_REQUIRED",
detail="start_at must include timezone",
),
) )
if next_end <= next_start: if next_end <= next_start:
raise HTTPException( raise ApiProblemError(
status_code=400, detail="end_at must be after start_at" status_code=400,
detail=problem_payload(
code="SCHEDULE_ITEM_INVALID_TIME_RANGE",
detail="end_at must be after start_at",
),
) )
if not update_data: if not update_data:
@@ -206,12 +240,22 @@ class ScheduleItemService(BaseService):
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to update schedule item", item_id=str(item_id)) logger.exception("Failed to update schedule item", item_id=str(item_id))
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Schedule item store unavailable" status_code=503,
detail=problem_payload(
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
detail="Schedule item store unavailable",
),
) )
if item is None: 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) return self._to_response(item)
@@ -221,7 +265,13 @@ class ScheduleItemService(BaseService):
try: try:
existing = await self._repository.get_by_item_id(item_id, user_id) existing = await self._repository.get_by_item_id(item_id, user_id)
if existing is None: 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 title = existing.title
await self._repository.delete_subscriptions_by_item_id(item_id) await self._repository.delete_subscriptions_by_item_id(item_id)
@@ -231,8 +281,12 @@ class ScheduleItemService(BaseService):
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to delete schedule item", item_id=str(item_id)) logger.exception("Failed to delete schedule item", item_id=str(item_id))
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Schedule item store unavailable" status_code=503,
detail=problem_payload(
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
detail="Schedule item store unavailable",
),
) )
async def list_by_date_range( async def list_by_date_range(
@@ -244,7 +298,13 @@ class ScheduleItemService(BaseService):
normalized_end_at = self._to_utc_required(request.end_at) normalized_end_at = self._to_utc_required(request.end_at)
if normalized_end_at <= normalized_start_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: try:
archived_count = await self._repository.archive_expired_subscribed_items( archived_count = await self._repository.archive_expired_subscribed_items(
@@ -275,8 +335,12 @@ class ScheduleItemService(BaseService):
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to list schedule items") logger.exception("Failed to list schedule items")
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Schedule item store unavailable" status_code=503,
detail=problem_payload(
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
detail="Schedule item store unavailable",
),
) )
async def list_paginated( async def list_paginated(
@@ -288,9 +352,23 @@ class ScheduleItemService(BaseService):
) -> tuple[list[ScheduleItemResponse], int]: ) -> tuple[list[ScheduleItemResponse], int]:
user_id = self.require_user_id() user_id = self.require_user_id()
if page < 1: 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: 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: try:
items, total = await self._repository.list_paginated( items, total = await self._repository.list_paginated(
user_id, user_id,
@@ -304,8 +382,12 @@ class ScheduleItemService(BaseService):
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Schedule item store unavailable" 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 return [self._to_response(item) for item in items], total
@@ -317,23 +399,39 @@ class ScheduleItemService(BaseService):
try: try:
item = await self._repository.get_by_id(item_id) item = await self._repository.get_by_id(item_id)
if item is None: 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 inviter_permission = SubscriptionPermission.OWNER
if item.owner_id != user_id: if item.owner_id != user_id:
inviter_sub = await self._repository.get_subscription(item_id, user_id) inviter_sub = await self._repository.get_subscription(item_id, user_id)
if inviter_sub is None: if inviter_sub is None:
raise HTTPException( raise ApiProblemError(
status_code=403, 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) inviter_permission = SubscriptionPermission(inviter_sub.permission)
request_permission = request._permission_value() request_permission = request._permission_value()
if request_permission > inviter_permission: if request_permission > inviter_permission:
raise HTTPException( raise ApiProblemError(
status_code=403, 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) 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 item_id, recipient_id, SubscriptionStatus.PENDING
) )
else: else:
raise HTTPException( raise ApiProblemError(
status_code=400, 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: else:
await self._repository.create_subscription( await self._repository.create_subscription(
@@ -368,14 +469,20 @@ class ScheduleItemService(BaseService):
) )
if existing_msg: if existing_msg:
if existing_msg.status == InboxMessageStatus.ACCEPTED: if existing_msg.status == InboxMessageStatus.ACCEPTED:
raise HTTPException( raise ApiProblemError(
status_code=400, 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: elif existing_msg.status == InboxMessageStatus.PENDING:
raise HTTPException( raise ApiProblemError(
status_code=400, 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: elif existing_msg.status == InboxMessageStatus.REJECTED:
existing_msg.status = InboxMessageStatus.PENDING existing_msg.status = InboxMessageStatus.PENDING
@@ -400,20 +507,30 @@ class ScheduleItemService(BaseService):
self._session.add(message) self._session.add(message)
await self._session.commit() await self._session.commit()
except HTTPException: except ApiProblemError:
raise raise
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to share schedule item", item_id=str(item_id)) logger.exception("Failed to share schedule item", item_id=str(item_id))
raise HTTPException( raise ApiProblemError(
status_code=503, detail="Schedule item store unavailable" status_code=503,
detail=problem_payload(
code="SCHEDULE_ITEM_STORE_UNAVAILABLE",
detail="Schedule item store unavailable",
),
) )
except ValueError: except ValueError:
await self._session.rollback() await self._session.rollback()
logger.exception( logger.exception(
"Auth lookup returned invalid user id", phone=request.phone "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") return ScheduleItemShareResponse(message="Calendar invitation sent")
@@ -460,8 +577,12 @@ class ScheduleItemService(BaseService):
item_id, user_id item_id, user_id
) )
if inbox is None: if inbox is None:
raise HTTPException( raise ApiProblemError(
status_code=404, detail="No pending invitation found" status_code=404,
detail=problem_payload(
code="SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND",
detail="No pending invitation found",
),
) )
content = inbox.content or {} content = inbox.content or {}
@@ -487,12 +608,18 @@ class ScheduleItemService(BaseService):
await self._session.commit() await self._session.commit()
return {"message": "Subscription accepted"} return {"message": "Subscription accepted"}
except HTTPException: except ApiProblemError:
raise raise
except Exception: except Exception:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to accept subscription") 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: async def reject_subscription(self, item_id: UUID) -> dict:
user_id = self.require_user_id() user_id = self.require_user_id()
@@ -502,8 +629,12 @@ class ScheduleItemService(BaseService):
item_id, user_id item_id, user_id
) )
if inbox is None: if inbox is None:
raise HTTPException( raise ApiProblemError(
status_code=404, detail="No pending invitation found" 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) existing = await self._repository.get_subscription(item_id, user_id)
@@ -516,12 +647,18 @@ class ScheduleItemService(BaseService):
await self._session.commit() await self._session.commit()
return {"message": "Subscription rejected"} return {"message": "Subscription rejected"}
except HTTPException: except ApiProblemError:
raise raise
except Exception: except Exception:
await self._session.rollback() await self._session.rollback()
logger.exception("Failed to reject subscription") 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( async def _notify_subscribers(
self, self,
@@ -560,13 +697,23 @@ class ScheduleItemService(BaseService):
if dt is None: if dt is None:
return None return None
if dt.tzinfo is None: if dt.tzinfo is None:
raise HTTPException( raise ApiProblemError(
status_code=400, detail="datetime must include timezone" status_code=400,
detail=problem_payload(
code="SCHEDULE_ITEM_DATETIME_TIMEZONE_REQUIRED",
detail="datetime must include timezone",
),
) )
return dt.astimezone(timezone.utc) return dt.astimezone(timezone.utc)
def _to_utc_required(self, dt: datetime) -> datetime: def _to_utc_required(self, dt: datetime) -> datetime:
normalized = self._to_utc(dt) normalized = self._to_utc(dt)
if normalized is None: 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 return normalized
+133 -29
View File
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.db.base_service import BaseService from core.db.base_service import BaseService
from core.http.errors import ApiProblemError
from core.logging import get_logger from core.logging import get_logger
from models.todos import Todo from models.todos import Todo
from schemas.enums import TodoStatus from schemas.enums import TodoStatus
@@ -29,6 +29,21 @@ if TYPE_CHECKING:
logger = get_logger("v1.todo.service") 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): class TodoService(BaseService):
"""Todo service handling todo CRUD operations. """Todo service handling todo CRUD operations.
@@ -84,7 +99,11 @@ class TodoService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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( logger.info(
"todo_created", "todo_created",
@@ -102,10 +121,18 @@ class TodoService(BaseService):
try: try:
todo = await self._repository.get_by_id(todo_id) todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError: 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: 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: if todo.owner_id != user_id:
logger.warning( logger.warning(
@@ -115,8 +142,11 @@ class TodoService(BaseService):
"todo_id": str(todo_id), "todo_id": str(todo_id),
}, },
) )
raise HTTPException( raise _todo_error(
status_code=403, detail="Not authorized to access this todo" status_code=403,
code="TODO_ACCESS_FORBIDDEN",
detail="Not authorized to access this todo",
params={"action": "access"},
) )
return await self._to_response(todo) return await self._to_response(todo)
@@ -127,10 +157,18 @@ class TodoService(BaseService):
try: try:
todo = await self._repository.get_by_id(todo_id) todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError: 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: 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: if todo.owner_id != user_id:
logger.warning( logger.warning(
@@ -140,8 +178,11 @@ class TodoService(BaseService):
"todo_id": str(todo_id), "todo_id": str(todo_id),
}, },
) )
raise HTTPException( raise _todo_error(
status_code=403, detail="Not authorized to update this todo" status_code=403,
code="TODO_ACCESS_FORBIDDEN",
detail="Not authorized to update this todo",
params={"action": "update"},
) )
completed_at = None completed_at = None
@@ -174,7 +215,11 @@ class TodoService(BaseService):
await self._session.refresh(todo) await self._session.refresh(todo)
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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( logger.info(
"todo_updated", "todo_updated",
@@ -192,10 +237,18 @@ class TodoService(BaseService):
try: try:
todo = await self._repository.get_by_id(todo_id) todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError: 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: 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: if todo.owner_id != user_id:
logger.warning( logger.warning(
@@ -205,8 +258,11 @@ class TodoService(BaseService):
"todo_id": str(todo_id), "todo_id": str(todo_id),
}, },
) )
raise HTTPException( raise _todo_error(
status_code=403, detail="Not authorized to complete this todo" status_code=403,
code="TODO_ACCESS_FORBIDDEN",
detail="Not authorized to complete this todo",
params={"action": "complete"},
) )
try: try:
@@ -219,7 +275,11 @@ class TodoService(BaseService):
await self._session.refresh(todo) await self._session.refresh(todo)
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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( logger.info(
"todo_completed", "todo_completed",
@@ -237,10 +297,18 @@ class TodoService(BaseService):
try: try:
todo = await self._repository.get_by_id(todo_id) todo = await self._repository.get_by_id(todo_id)
except SQLAlchemyError: 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: 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: if todo.owner_id != user_id:
logger.warning( logger.warning(
@@ -250,8 +318,11 @@ class TodoService(BaseService):
"todo_id": str(todo_id), "todo_id": str(todo_id),
}, },
) )
raise HTTPException( raise _todo_error(
status_code=403, detail="Not authorized to delete this todo" status_code=403,
code="TODO_ACCESS_FORBIDDEN",
detail="Not authorized to delete this todo",
params={"action": "delete"},
) )
try: try:
@@ -259,7 +330,11 @@ class TodoService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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( logger.info(
"todo_deleted", "todo_deleted",
@@ -279,15 +354,26 @@ class TodoService(BaseService):
try: try:
for item in request.items: for item in request.items:
if item.id in seen_ids: 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) seen_ids.add(item.id)
todo = await self._repository.get_by_id(item.id) todo = await self._repository.get_by_id(item.id)
if todo is None: 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: if todo.owner_id != user_id:
raise HTTPException( raise _todo_error(
status_code=403, detail="Not authorized to reorder this todo" status_code=403,
code="TODO_ACCESS_FORBIDDEN",
detail="Not authorized to reorder this todo",
params={"action": "reorder"},
) )
original_priorities.add(todo.priority) original_priorities.add(todo.priority)
@@ -314,7 +400,11 @@ class TodoService(BaseService):
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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( async def list_todos(
self, self,
@@ -328,10 +418,20 @@ class TodoService(BaseService):
try: try:
status_enum = TodoStatus(status) status_enum = TodoStatus(status)
except ValueError: 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): 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: try:
todos = await self._repository.list_by_owner( todos = await self._repository.list_by_owner(
@@ -340,7 +440,11 @@ class TodoService(BaseService):
priority=priority, priority=priority,
) )
except SQLAlchemyError: 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] return [await self._to_response(todo) for todo in todos]
+33 -8
View File
@@ -4,7 +4,7 @@ import asyncio
from typing import Annotated from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import Depends, Header, HTTPException from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.jwt_verifier import ( from core.auth.jwt_verifier import (
@@ -14,6 +14,7 @@ from core.auth.jwt_verifier import (
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.config.settings import config from core.config.settings import config
from core.db import get_db from core.db import get_db
from core.http.errors import ApiProblemError
from core.logging import get_logger from core.logging import get_logger
from services.base.supabase import supabase_service from services.base.supabase import supabase_service
from v1.auth.gateway import SupabaseAuthGateway from v1.auth.gateway import SupabaseAuthGateway
@@ -44,7 +45,11 @@ def get_jwt_verifier() -> JwtVerifier:
) )
if not issuer or not jwt_secret: if not issuer or not jwt_secret:
logger.error("JWT validation failed: verifier config not configured") 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( _jwt_verifier = JwtVerifier(
issuer=issuer, issuer=issuer,
jwt_secret=jwt_secret, jwt_secret=jwt_secret,
@@ -90,16 +95,24 @@ async def get_current_user(
) -> CurrentUser: ) -> CurrentUser:
if not authorization: if not authorization:
logger.warning("JWT validation failed: missing authorization header") 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(" ") scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token: if scheme.lower() != "bearer" or not token:
logger.warning("JWT validation failed: invalid authorization scheme") 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: try:
payload = get_jwt_verifier().verify(token) payload = get_jwt_verifier().verify(token)
except HTTPException: except ApiProblemError:
raise raise
except TokenValidationError as exc: except TokenValidationError as exc:
logger.warning( logger.warning(
@@ -109,20 +122,32 @@ async def get_current_user(
) )
fallback_user = await _verify_user_with_supabase(token) fallback_user = await _verify_user_with_supabase(token)
if fallback_user is None: 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)) logger.info("JWT fallback validation succeeded", user_id=str(fallback_user.id))
return fallback_user return fallback_user
subject = payload.get("sub") subject = payload.get("sub")
if not isinstance(subject, str) or not subject: if not isinstance(subject, str) or not subject:
logger.warning("JWT validation failed: missing or invalid subject claim") 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: try:
user_id = UUID(subject) user_id = UUID(subject)
except ValueError: except ValueError:
logger.warning("JWT validation failed: invalid UUID in subject") 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)) logger.debug("JWT validation successful", user_id=str(user_id))
phone = payload.get("phone") if isinstance(payload.get("phone"), str) else None phone = payload.get("phone") if isinstance(payload.get("phone"), str) else None
+111 -22
View File
@@ -4,7 +4,6 @@ import re
from typing import TYPE_CHECKING, Protocol, cast from typing import TYPE_CHECKING, Protocol, cast
from uuid import UUID from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.agentscope.caches.user_context_cache import ( 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.auth.models import CurrentUser
from core.config.settings import config from core.config.settings import config
from core.db.base_service import BaseService from core.db.base_service import BaseService
from core.http.errors import ApiProblemError
from core.logging import get_logger from core.logging import get_logger
from schemas.shared.user import UserContext, parse_profile_settings from schemas.shared.user import UserContext, parse_profile_settings
from services.base.supabase import supabase_service 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}$") _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: def _mime_to_suffix(mime_type: str) -> str:
"""Convert MIME type to file suffix.""" """Convert MIME type to file suffix."""
mapping = { mapping = {
@@ -62,7 +77,7 @@ class AuthLookupAdapter:
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]: async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
try: try:
return await self._gateway.search_user_ids_by_phone(query, limit=limit) return await self._gateway.search_user_ids_by_phone(query, limit=limit)
except HTTPException: except ApiProblemError:
return [] return []
@@ -102,10 +117,18 @@ class UserService(BaseService):
try: try:
user = await self._repository.get_by_user_id(user_id) user = await self._repository.get_by_user_id(user_id)
except SQLAlchemyError: 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: 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 phone = self._current_user.phone if self._current_user else None
return UserContext( return UserContext(
id=str(user.id), id=str(user.id),
@@ -122,10 +145,18 @@ class UserService(BaseService):
try: try:
profile = await self._repository.get_by_user_id(user_id) profile = await self._repository.get_by_user_id(user_id)
except SQLAlchemyError: 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: 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( return UserContext(
id=str(profile.id), id=str(profile.id),
username=profile.username, username=profile.username,
@@ -145,17 +176,29 @@ class UserService(BaseService):
} }
if not update_data: 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: try:
user = await self._repository.update_by_user_id(user_id, update_data) user = await self._repository.update_by_user_id(user_id, update_data)
await self._session.commit() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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: 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: try:
await self._user_context_cache.invalidate_user(user_id=user_id) await self._user_context_cache.invalidate_user(user_id=user_id)
@@ -186,25 +229,39 @@ class UserService(BaseService):
user_id = self.require_user_id() user_id = self.require_user_id()
if not isinstance(content_type, str): 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() mime_type = content_type.lower()
allowed_types = {"image/jpeg", "image/png", "image/webp"} allowed_types = {"image/jpeg", "image/png", "image/webp"}
if mime_type not in allowed_types: if mime_type not in allowed_types:
raise HTTPException( raise _user_error(
status_code=422, status_code=422,
code="USER_AVATAR_UNSUPPORTED_TYPE",
detail="Unsupported image type. Allowed: JPEG, PNG, WebP", 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 max_size_bytes = config.storage.avatar.max_size_mb * 1024 * 1024
if len(payload) > max_size_bytes: if len(payload) > max_size_bytes:
raise HTTPException( raise _user_error(
status_code=413, 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: 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) suffix = _mime_to_suffix(mime_type)
path = f"{user_id}/avatar.{suffix}" path = f"{user_id}/avatar.{suffix}"
@@ -227,7 +284,11 @@ class UserService(BaseService):
"user_id": str(user_id), "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}" 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() await self._session.commit()
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback() 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: 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: try:
await self._user_context_cache.invalidate_user(user_id=user_id) await self._user_context_cache.invalidate_user(user_id=user_id)
@@ -257,10 +326,18 @@ class UserService(BaseService):
try: try:
user = await self._repository.get_by_username(username) user = await self._repository.get_by_username(username)
except SQLAlchemyError: 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: 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( return UserContext(
id=str(user.id), id=str(user.id),
username=user.username, username=user.username,
@@ -288,7 +365,11 @@ class UserService(BaseService):
async def _search_by_phone(self, phone: str) -> list[UserContext]: async def _search_by_phone(self, phone: str) -> list[UserContext]:
if self._auth_gateway is None: 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( user_id_values = await self._auth_gateway.search_user_ids_by_phone(
phone, limit=20 phone, limit=20
@@ -308,7 +389,11 @@ class UserService(BaseService):
try: try:
users_by_id = await self._repository.get_by_user_ids(user_ids) users_by_id = await self._repository.get_by_user_ids(user_ids)
except SQLAlchemyError: 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] = [] results: list[UserContext] = []
for user_id in user_ids: for user_id in user_ids:
@@ -330,7 +415,11 @@ class UserService(BaseService):
try: try:
users = await self._repository.search_users(query, limit=20) users = await self._repository.search_users(query, limit=20)
except SQLAlchemyError: 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 [ return [
UserContext( UserContext(
@@ -12,6 +12,8 @@ def test_problem_details_defaults() -> None:
assert result.status == 401 assert result.status == 401
assert result.detail == "Unauthorized" assert result.detail == "Unauthorized"
assert result.instance is None assert result.instance is None
assert result.code is None
assert result.params is None
def test_problem_details_overrides() -> None: def test_problem_details_overrides() -> None:
@@ -21,6 +23,8 @@ def test_problem_details_overrides() -> None:
type_value="https://example.com/problems/conflict", type_value="https://example.com/problems/conflict",
title="Conflict", title="Conflict",
instance="/api/mobile/auth/signup", instance="/api/mobile/auth/signup",
code="AUTH_CONFLICT",
params={"field": "email"},
) )
assert result.type == "https://example.com/problems/conflict" assert result.type == "https://example.com/problems/conflict"
@@ -28,3 +32,5 @@ def test_problem_details_overrides() -> None:
assert result.status == 409 assert result.status == 409
assert result.detail == "Conflict" assert result.detail == "Conflict"
assert result.instance == "/api/mobile/auth/signup" assert result.instance == "/api/mobile/auth/signup"
assert result.code == "AUTH_CONFLICT"
assert result.params == {"field": "email"}
@@ -4,7 +4,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from fastapi import HTTPException from core.http.errors import ApiProblemError
from v1.auth.gateway import SupabaseAuthGateway from v1.auth.gateway import SupabaseAuthGateway
from v1.auth.schemas import ( from v1.auth.schemas import (
@@ -101,7 +101,7 @@ class TestSupabaseAuthGateway:
return_value=SimpleNamespace(session=None, user=None) 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")) await sut.refresh_session(SessionRefreshRequest(refresh_token="bad"))
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.http.errors import ApiProblemError
from models.automation_jobs import AutomationJobStatus, ScheduleType from models.automation_jobs import AutomationJobStatus, ScheduleType
from v1.automation_jobs.service import ( from v1.automation_jobs.service import (
@@ -203,7 +203,7 @@ class TestCreate:
repository.count_user_jobs.return_value = 0 repository.count_user_jobs.return_value = 0
repository.create.side_effect = SQLAlchemyError("db down") 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) await service.create(owner_id, data)
assert exc.value.status_code == 503 assert exc.value.status_code == 503
@@ -316,7 +316,7 @@ class TestUpdate:
repository.get_by_id.return_value = job repository.get_by_id.return_value = job
repository.update.side_effect = SQLAlchemyError("db down") repository.update.side_effect = SQLAlchemyError("db down")
with pytest.raises(HTTPException) as exc: with pytest.raises(ApiProblemError) as exc:
await service.update( await service.update(
job.id, job.id,
owner_id, owner_id,
@@ -391,7 +391,7 @@ class TestDelete:
repository.get_by_id.return_value = job repository.get_by_id.return_value = job
repository.soft_delete.side_effect = SQLAlchemyError("db down") 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) await service.delete(job.id, owner_id)
assert exc.value.status_code == 503 assert exc.value.status_code == 503
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException from core.http.errors import ApiProblemError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from models.friendships import Friendship, FriendshipStatus from models.friendships import Friendship, FriendshipStatus
@@ -293,7 +293,7 @@ class TestSendRequest:
current_user=current_user, current_user=current_user,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(ApiProblemError) as exc_info:
await service.send_request( await service.send_request(
FriendRequestCreate(target_user_id=current_user.id, content=None) FriendRequestCreate(target_user_id=current_user.id, content=None)
) )
@@ -322,7 +322,7 @@ class TestSendRequest:
current_user=current_user, current_user=current_user,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(ApiProblemError) as exc_info:
await service.send_request( await service.send_request(
FriendRequestCreate(target_user_id=USER_B, content=None) FriendRequestCreate(target_user_id=USER_B, content=None)
) )
@@ -351,7 +351,7 @@ class TestSendRequest:
current_user=current_user, current_user=current_user,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(ApiProblemError) as exc_info:
await service.send_request( await service.send_request(
FriendRequestCreate(target_user_id=USER_B, content=None) FriendRequestCreate(target_user_id=USER_B, content=None)
) )
@@ -411,7 +411,7 @@ class TestAcceptRequest:
current_user=current_user, current_user=current_user,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(ApiProblemError) as exc_info:
await service.accept_request(uuid4()) await service.accept_request(uuid4())
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404
@@ -447,7 +447,7 @@ class TestAcceptRequest:
current_user=current_user, current_user=current_user,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(ApiProblemError) as exc_info:
await service.accept_request(friendship.id) await service.accept_request(friendship.id)
assert exc_info.value.status_code == 403 assert exc_info.value.status_code == 403
@@ -669,7 +669,7 @@ class TestRemoveFriend:
current_user=current_user, current_user=current_user,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(ApiProblemError) as exc_info:
await service.remove_friend(uuid4()) await service.remove_friend(uuid4())
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404
@@ -3,10 +3,10 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError
from models.inbox_messages import ( from models.inbox_messages import (
InboxMessage, InboxMessage,
InboxMessageStatus as InboxMessageModelStatus, 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), 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) await service.mark_as_read(message_id)
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Inbox message not found" assert exc_info.value.detail == "Inbox message not found"
assert exc_info.value.code == "INBOX_MESSAGE_NOT_FOUND"
session.commit.assert_not_awaited() 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), 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) await service.mark_as_read(message_id)
assert exc_info.value.status_code == 503 assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Inbox message store unavailable" assert exc_info.value.detail == "Inbox message store unavailable"
assert exc_info.value.code == "INBOX_MESSAGE_STORE_UNAVAILABLE"
session.rollback.assert_awaited_once() session.rollback.assert_awaited_once()
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException from core.http.errors import ApiProblemError
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
@@ -198,7 +198,7 @@ async def test_create_invalid_end_at(
inbox_repository=mock_inbox_repository, inbox_repository=mock_inbox_repository,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(ApiProblemError) as exc_info:
await service.create(request) await service.create(request)
assert exc_info.value.status_code == 400 assert exc_info.value.status_code == 400
@@ -234,7 +234,7 @@ async def test_get_by_id_not_found(
inbox_repository=mock_inbox_repository, 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()) await service.get_by_id(uuid4())
assert exc_info.value.status_code == 404 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, 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( await service.list_by_date_range(
request=ScheduleItemListRequest( request=ScheduleItemListRequest(
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc), start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
@@ -3,7 +3,7 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
import pytest import pytest
from fastapi import HTTPException from core.http.errors import ApiProblemError
from core.auth.jwt_verifier import TokenValidationError from core.auth.jwt_verifier import TokenValidationError
import v1.users.dependencies as deps 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) 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") await deps.get_current_user(authorization="Bearer invalid-token")
assert exc.value.status_code == 401 assert exc.value.status_code == 401