Why: - Plan 001 Phase 4 needs batched, concurrency-bounded embedding wired into the inline upload path, with process-wide capacity/timeout/chunk-limit guards (ADR-0017). - The BM25 analyzer and dense-model config are ported from the `emet` evaluation lab, which benchmarked them against the real Farsi corpus (bm25-fa-norm-stop; nomic-embed-text-v2-moe at 768-dim; text-embedding-3-large at native 3072-dim), closing open items in ADR-0001/ADR-0005. Changes: - New: embedding ports, orchestration (embed_chunks), request-bounds helpers, and dense/sparse adapters (analyzers.py, bm25.py, openai_compatible.py). - upload.py now parses/chunks/embeds inline behind INGESTION_MAX_CONCURRENCY (503), INGESTION_TIMEOUT_SECONDS (504), and the chunk-count ceiling (413); every failure path still writes a terminal job row. - Lifespan builds and warms both dense embedders at startup (fail-soft) and creates the sparse embedder and concurrency semaphore. - httpx moves from dev to main dependencies (adapters use it directly). Impact: - Qdrant point upserts are still Phase 5 -- chunks_indexed stays 0. - New EMBEDDING_* env vars documented in .env.example; safe defaults. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
121 lines
4.6 KiB
Python
121 lines
4.6 KiB
Python
"""Maps application exceptions to the ADR-0008 error envelope.
|
|
|
|
This is the single place that knows the exception-type -> status-code
|
|
mapping; application/infrastructure code never imports FastAPI or raises
|
|
`HTTPException` (ADR-0015).
|
|
"""
|
|
|
|
import structlog
|
|
from fastapi import FastAPI, Request, status
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from src.application.auth.errors import (
|
|
InvalidApiKeyError,
|
|
MissingScopeError,
|
|
TenantInactiveError,
|
|
)
|
|
from src.application.files.errors import FileTooLargeError, InvalidUploadError
|
|
from src.application.ingestion.errors import (
|
|
ChunkLimitExceededError,
|
|
DocumentParseError,
|
|
EmbedderError,
|
|
IngestionAtCapacityError,
|
|
IngestionTimeoutError,
|
|
UnsupportedSourceTypeError,
|
|
)
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
# A fixed backoff hint, not a computed retry budget: ADR-0017 rejects a
|
|
# request outright at capacity rather than queueing it, so there is no
|
|
# in-process estimate of when a slot will free up to report instead.
|
|
_CAPACITY_RETRY_AFTER_SECONDS = 1
|
|
|
|
# (exception type, status code, stable error code)
|
|
_MAPPING: tuple[tuple[type[Exception], int, str], ...] = (
|
|
(InvalidApiKeyError, status.HTTP_401_UNAUTHORIZED, "invalid_api_key"),
|
|
(TenantInactiveError, status.HTTP_401_UNAUTHORIZED, "tenant_not_found"),
|
|
(MissingScopeError, status.HTTP_403_FORBIDDEN, "missing_scope"),
|
|
(InvalidUploadError, status.HTTP_400_BAD_REQUEST, "validation_error"),
|
|
(DocumentParseError, status.HTTP_400_BAD_REQUEST, "validation_error"),
|
|
(UnsupportedSourceTypeError, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "unsupported_media_type"),
|
|
(FileTooLargeError, status.HTTP_413_CONTENT_TOO_LARGE, "payload_too_large"),
|
|
(ChunkLimitExceededError, status.HTTP_413_CONTENT_TOO_LARGE, "payload_too_large"),
|
|
(EmbedderError, status.HTTP_502_BAD_GATEWAY, "embedder_error"),
|
|
(IngestionTimeoutError, status.HTTP_504_GATEWAY_TIMEOUT, "ingestion_timeout"),
|
|
)
|
|
|
|
|
|
def _request_id(request: Request) -> str | None:
|
|
return getattr(request.state, "request_id", None)
|
|
|
|
|
|
def _envelope(
|
|
code: str, message: str, request_id: str | None, details: dict[str, object] | None = None
|
|
) -> dict[str, object]:
|
|
return {
|
|
"error": {
|
|
"code": code,
|
|
"message": message,
|
|
"details": details or {},
|
|
"request_id": request_id,
|
|
}
|
|
}
|
|
|
|
|
|
def register_exception_handlers(app: FastAPI) -> None:
|
|
for exc_type, status_code, error_code in _MAPPING:
|
|
|
|
def _handler(
|
|
request: Request,
|
|
exc: Exception,
|
|
status_code: int = status_code,
|
|
error_code: str = error_code,
|
|
) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content=_envelope(error_code, str(exc), _request_id(request)),
|
|
)
|
|
|
|
app.add_exception_handler(exc_type, _handler)
|
|
|
|
@app.exception_handler(IngestionAtCapacityError)
|
|
def _capacity_handler(request: Request, exc: IngestionAtCapacityError) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
content=_envelope("ingestion_at_capacity", str(exc), _request_id(request)),
|
|
headers={"Retry-After": str(_CAPACITY_RETRY_AFTER_SECONDS)},
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
content=_envelope(
|
|
"validation_error",
|
|
"request validation failed",
|
|
_request_id(request),
|
|
details={"errors": exc.errors()},
|
|
),
|
|
)
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
def _http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
code = "not_found" if exc.status_code == status.HTTP_404_NOT_FOUND else "http_error"
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=_envelope(code, str(exc.detail), _request_id(request)),
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
logger.exception("api.unhandled_exception", path=request.url.path)
|
|
return JSONResponse(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
content=_envelope(
|
|
"internal_error", "an unexpected error occurred", _request_id(request)
|
|
),
|
|
)
|