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