Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,72 +1,78 @@
DC = docker compose -f docker-compose.yml -f docker-compose.dev.yml
DC_RUN = $(DC) run --rm --no-deps app

.PHONY: build dev-docker dev-docker-build test-docker lint-docker fmt-docker typecheck-docker ci-docker \
.DEFAULT_GOAL := help

.PHONY: help build dev-docker dev-docker-build test-docker test-integration-docker \
lint-docker fmt-docker fmt-check-docker typecheck-docker ci-docker \
logs start run stop clean configure \
sync test lint fmt typecheck ci

help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}'

# ── Docker (primary workflow) ─────────────────────────────────────────────────
build:
build: ## Build the docker image
docker compose build

dev-docker:
dev-docker: ## Run the dev stack in the foreground (docker)
$(DC) up

dev-docker-build:
dev-docker-build: ## Rebuild then run the dev stack (docker)
$(DC) up --build

test-docker:
test-docker: ## Run unit/route tests (docker)
$(DC_RUN) pytest -m 'not integration and not slow' -v

test-integration-docker:
test-integration-docker: ## Run integration tests (docker)
$(DC_RUN) pytest -m integration -v

lint-docker:
lint-docker: ## Run ruff check (docker)
$(DC_RUN) ruff check app/ tests/

fmt-docker:
fmt-docker: ## Run ruff format (docker)
$(DC_RUN) ruff format app/ tests/

fmt-check-docker:
fmt-check-docker: ## Check ruff formatting without writing (docker)
$(DC_RUN) ruff format --check app/ tests/

typecheck-docker:
typecheck-docker: ## Run mypy (docker)
$(DC_RUN) mypy app/

ci-docker: lint-docker fmt-check-docker typecheck-docker test-docker
ci-docker: lint-docker fmt-check-docker typecheck-docker test-docker ## Run full CI suite (docker)

logs:
logs: ## Tail the app container logs
docker compose logs -f app

start:
start: ## Start the stack in the background
docker compose up -d

run: start
run: start ## Alias for start

stop:
stop: ## Stop the stack
docker compose down

configure:
configure: ## Run the interactive setup script
bash scripts/configure.sh

clean:
clean: ## Remove __pycache__ and .pyc files
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -name '*.pyc' -delete 2>/dev/null || true

# ── Local fallback (requires uv + dev deps installed locally) ─────────────────
sync:
sync: ## Install/sync dev dependencies locally (uv)
uv sync --dev

test:
test: ## Run unit/route tests locally (uv)
uv run pytest -m 'not integration and not slow' -v

lint:
lint: ## Run ruff check locally (uv)
uv run ruff check app/ tests/

fmt:
fmt: ## Run ruff format locally (uv)
uv run ruff format app/ tests/

typecheck:
typecheck: ## Run mypy locally (uv)
uv run mypy app/

ci: lint fmt typecheck test
ci: lint fmt typecheck test ## Run full CI suite locally (uv)
79 changes: 65 additions & 14 deletions app/api/errors.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import http
from typing import Any

from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException, RequestValidationError
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException

PROBLEM_MEDIA_TYPE = "application/problem+json"
ERROR_BASE = "https://readium.org/speech-server/error#"


class ErrorBody(BaseModel):
code: str
message: str
class ProblemDetail(BaseModel):
type: str
title: str
status: int
detail: str | None = None
instance: str | None = None
errors: list[dict[str, Any]] | None = None


class ErrorResponse(BaseModel):
error: ErrorBody
def problem_response(description: str) -> dict[str, Any]:
return {"model": ProblemDetail, "description": description}


# --- exception hierarchy ---
Expand All @@ -20,6 +31,7 @@ class ErrorResponse(BaseModel):
class AppError(Exception):
status_code: int = 500
code: str = "internal_error"
title: str = "Internal Server Error"

def __init__(self, message: str, detail: str | None = None) -> None:
super().__init__(message)
Expand All @@ -30,51 +42,75 @@ def __init__(self, message: str, detail: str | None = None) -> None:
class RequestValidationFailed(AppError):
status_code = 400
code = "validation_failed"
title = "Invalid Request"


class VoiceNotFound(AppError):
status_code = 404
code = "voice_not_found"
title = "Voice Not Found"


class UnsupportedFormat(AppError):
status_code = 415
code = "unsupported_format"
title = "Unsupported Format"


class PayloadTooLarge(AppError):
status_code = 413
code = "payload_too_large"
title = "Payload Too Large"


class RateLimited(AppError):
status_code = 429
code = "rate_limited"
title = "Too Many Requests"


class ProviderError(AppError):
status_code = 502
code = "provider_error"
title = "Provider Error"


class ProviderTimeout(AppError):
status_code = 504
code = "provider_timeout"
title = "Provider Timeout"


class ServiceNotReady(AppError):
status_code = 503
code = "service_not_ready"
title = "Service Not Ready"


# --- helpers ---


def _error_response(
status: int, code: str, message: str, detail: str | None = None
def _problem_response(
request: Request,
status: int,
type_: str,
title: str,
detail: str | None = None,
errors: list[dict[str, Any]] | None = None,
) -> JSONResponse:
body = ErrorResponse(error=ErrorBody(code=code, message=message, detail=detail))
return JSONResponse(status_code=status, content=body.model_dump())
body = ProblemDetail(
type=type_,
title=title,
status=status,
detail=detail,
instance=f"urn:uuid:{request.state.request_id}",
errors=errors,
)
return JSONResponse(
status_code=status,
content=body.model_dump(exclude_none=True),
media_type=PROBLEM_MEDIA_TYPE,
)


# --- error handlers ---
Expand All @@ -83,21 +119,36 @@ def _error_response(
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return _error_response(exc.status_code, exc.code, exc.message, exc.detail)
detail = f"{exc.message}: {exc.detail}" if exc.detail else exc.message
return _problem_response(request, exc.status_code, ERROR_BASE + exc.code, exc.title, detail)

@app.exception_handler(HTTPException)
async def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
return _error_response(exc.status_code, "http_error", str(exc.detail))
title = http.HTTPStatus(exc.status_code).phrase
return _problem_response(request, exc.status_code, "about:blank", title, str(exc.detail))

@app.exception_handler(RequestValidationError)
async def handle_validation_error(
request: Request, exc: RequestValidationError
) -> JSONResponse:
return _error_response(422, "validation_failed", "Request validation failed", str(exc))
return _problem_response(
request,
422,
ERROR_BASE + "validation_failed",
"Invalid Request",
"Request validation failed",
errors=jsonable_encoder(exc.errors()),
)

@app.exception_handler(Exception)
async def handle_unhandled(request: Request, exc: Exception) -> JSONResponse:
import logging

logging.getLogger("app.errors").exception("Unhandled error")
return _error_response(500, "internal_error", "An unexpected error occurred.")
return _problem_response(
request,
AppError.status_code,
ERROR_BASE + AppError.code,
AppError.title,
"An unexpected error occurred.",
)
12 changes: 5 additions & 7 deletions app/api/v1/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from app.api.errors import ServiceNotReady, problem_response
from app.config.settings import settings
from app.drivers import ffmpeg as ffmpeg_driver

Expand All @@ -25,22 +26,19 @@ async def healthz() -> HealthResponse:
@router.get(
"/readyz",
response_model=HealthResponse,
responses={503: {"description": "Service not ready"}},
responses={503: problem_response("Service not ready")},
summary="Readiness probe",
description="Returns 200 when the app is ready (models loaded, deps available). 503 otherwise.",
)
async def readyz(request: Request) -> JSONResponse:
ready: bool = getattr(request.app.state, "ready", False)
if not ready:
return JSONResponse(status_code=503, content={"status": "not ready"})
raise ServiceNotReady("not ready")
if not ffmpeg_driver.is_available(settings.ffmpeg_bin):
return JSONResponse(status_code=503, content={"status": "ffmpeg not found"})
raise ServiceNotReady("ffmpeg not found")
registry = getattr(request.app.state, "registry", None)
if registry:
for provider in registry.all():
if not await provider.health():
return JSONResponse(
status_code=503,
content={"status": f"provider '{provider.id}' not ready"},
)
raise ServiceNotReady(f"provider '{provider.id}' not ready")
return JSONResponse(status_code=200, content={"status": "ok"})
12 changes: 6 additions & 6 deletions app/api/v1/routes/synthesize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi.responses import JSONResponse, StreamingResponse

from app.api.deps import SynthesizerDep
from app.api.errors import ErrorResponse
from app.api.errors import problem_response
from app.schemas.utterance import SynthesizeRequest

router = APIRouter(tags=["synthesize"])
Expand All @@ -27,11 +27,11 @@
"or `application/json` with base64 audio + boundaries (`boundary: true`)."
),
},
400: {"model": ErrorResponse, "description": "Empty or whitespace text"},
404: {"model": ErrorResponse, "description": "Voice URI not found"},
413: {"model": ErrorResponse, "description": "Text exceeds max length"},
415: {"model": ErrorResponse, "description": "Unsupported audio format"},
422: {"model": ErrorResponse, "description": "Request schema validation error"},
400: problem_response("Empty or whitespace text"),
404: problem_response("Voice URI not found"),
413: problem_response("Text exceeds max length"),
415: problem_response("Unsupported audio format"),
422: problem_response("Request schema validation error"),
},
openapi_extra={
"requestBody": {
Expand Down
4 changes: 2 additions & 2 deletions app/api/v1/routes/voices.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Query, Response

from app.api.deps import VoiceCatalogDep
from app.api.errors import ErrorResponse
from app.api.errors import problem_response
from app.schemas.voice import Voice

router = APIRouter(tags=["voices"])
Expand All @@ -19,7 +19,7 @@
"Response headers `X-Total-Count`, `X-Offset`, `X-Limit` reflect the full result set size."
),
responses={
502: {"model": ErrorResponse, "description": "Provider unavailable"},
502: problem_response("Provider unavailable"),
},
openapi_extra={
"responses": {
Expand Down
30 changes: 29 additions & 1 deletion tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,35 @@
async def test_text_too_long_returns_413(client: AsyncClient) -> None:
resp = await client.post(_URL, json={"text": "x" * 2001, "voice": _VOICE})
assert resp.status_code == 413
assert resp.json()["error"]["code"] == "payload_too_large"
assert resp.headers["content-type"] == "application/problem+json"
body = resp.json()
assert body["type"] == "https://readium.org/speech-server/error#payload_too_large"
assert body["title"] == "Payload Too Large"
assert body["status"] == 413
assert "received 2001" in body["detail"]
assert body["instance"] == f"urn:uuid:{resp.headers['x-request-id']}"
assert "errors" not in body


@pytest.mark.route
async def test_unknown_route_returns_about_blank_problem(client: AsyncClient) -> None:
resp = await client.get("/v1/does-not-exist")
assert resp.status_code == 404
assert resp.headers["content-type"] == "application/problem+json"
body = resp.json()
assert body["type"] == "about:blank"
assert body["title"] == "Not Found"
assert body["status"] == 404


@pytest.mark.route
async def test_validation_error_includes_field_errors(client: AsyncClient) -> None:
resp = await client.post(_URL, json={"text": "Hello"}) # missing required "voice"
assert resp.status_code == 422
body = resp.json()
assert body["type"] == "https://readium.org/speech-server/error#validation_failed"
assert body["title"] == "Invalid Request"
assert any(err["loc"] == ["body", "voice"] for err in body["errors"])


@pytest.mark.route
Expand Down
2 changes: 1 addition & 1 deletion tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ async def test_readyz_before_ready(app: FastAPI) -> None:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.get("/readyz")
assert resp.status_code == 503
assert resp.json() == {"status": "not ready"}
assert resp.json()["type"] == "https://readium.org/speech-server/error#service_not_ready"
Loading
Loading