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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ wheels/

# Virtual environments
.venv
/docs/SPEC.md
Empty file added app/api/__init__.py
Empty file.
1 change: 1 addition & 0 deletions app/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Version 1 API routes."""
5 changes: 5 additions & 0 deletions app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from fastapi import APIRouter, Depends

from app.core.security import verify_api_key

api_router = APIRouter(dependencies=[Depends(verify_api_key)])
Empty file added app/core/__init__.py
Empty file.
88 changes: 88 additions & 0 deletions app/core/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

from app.core.exceptions import FlintException
from app.core.logger import get_logger
from app.schemas.response import ApiResponse, ErrorDetail

logger = get_logger(__name__)


def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(FlintException, flint_exception_handler) # type: ignore
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
app.add_exception_handler(Exception, fallback_exception_handler)


async def flint_exception_handler(
request: Request,
exc: FlintException,
) -> JSONResponse:
logger.warning(
"domain_exception",
path=request.url.path,
status_code=exc.status_code,
error=exc.message,
)
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse[None](
message=exc.message,
errors=[ErrorDetail(message=exc.message)],
).model_dump(mode="json"),
)


async def http_exception_handler(
request: Request,
exc: HTTPException,
) -> JSONResponse:
message = str(exc.detail) if exc.detail else "HTTP error"
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse[None](
message=message,
errors=[ErrorDetail(message=message)],
).model_dump(mode="json"),
headers=exc.headers,
)


async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
errors = [
ErrorDetail(
field=".".join(str(part) for part in error["loc"]),
message=str(error["msg"]),
)
for error in exc.errors()
]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=ApiResponse[None](
message="Validation failed",
errors=errors,
).model_dump(mode="json"),
)


async def fallback_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
logger.exception(
"unhandled_exception",
path=request.url.path,
error=str(exc),
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=ApiResponse[None](
message="Internal server error",
errors=[ErrorDetail(message="An unexpected error occurred.")],
).model_dump(mode="json"),
)
167 changes: 167 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
class FlintException(Exception):
"""
Base exception for all Flint domain errors.
"""

def __init__(self, message: str, status_code: int = 400) -> None:
self.message = message
self.status_code = status_code
super().__init__(message)


class JobNotFoundException(FlintException):
"""
Raised when a requested job does not exist or has been hard-deleted.
"""

def __init__(self, job_id: str) -> None:
super().__init__(
message=f"Job '{job_id}' not found.",
status_code=404,
)


class JobNotCancellableException(FlintException):
"""
Raised when a cancel request is made on a job in a terminal state
(completed, failed, cancelled).
"""

def __init__(self, job_id: str, status: str) -> None:
super().__init__(
message=(
f"Job '{job_id}' cannot be cancelled because it is already '{status}'. "
"Only pending and processing jobs can be cancelled."
),
status_code=409,
)


class JobNotDeletableException(FlintException):
"""
Raised when a soft-delete is attempted on a job that is still
pending or processing.
"""

def __init__(self, job_id: str, status: str) -> None:
super().__init__(
message=(
f"Job '{job_id}' cannot be deleted while it is '{status}'. "
"Cancel the job first, or wait for it to reach a terminal state."
),
status_code=409,
)


class JobAlreadyProcessingException(FlintException):
"""
Raised when two workers attempt to claim the same job.
"""

def __init__(self, job_id: str) -> None:
super().__init__(
message=f"Job '{job_id}' is already being processed by another worker.",
status_code=409,
)


class JobNotInBinException(FlintException):
"""
Raised when a restore or hard-delete is attempted on a non-deleted job.
"""

def __init__(self, job_id: str) -> None:
super().__init__(
message=f"Job '{job_id}' is not in the bin.",
status_code=404,
)


class JobNotInDLQException(FlintException):
"""
Raised when a DLQ retry is attempted on a job that is not in the DLQ.
"""

def __init__(self, job_id: str) -> None:
super().__init__(
message=f"Job '{job_id}' is not in the dead letter queue.",
status_code=404,
)


class DependencyCycleException(FlintException):
"""
Raised when adding a dependency would create a cycle in the
job dependency graph.
"""

def __init__(self, job_id: str, dependency_id: str) -> None:
super().__init__(
message=(
f"Adding dependency '{dependency_id}' to job '{job_id}' "
"would create a cycle in the dependency graph. "
"Circular dependencies are not allowed."
),
status_code=422,
)


class DependencyNotFoundException(FlintException):
"""Raised when a specified dependency job ID does not exist."""

def __init__(self, dependency_id: str) -> None:
super().__init__(
message=f"Dependency job '{dependency_id}' not found.",
status_code=404,
)


class HandlerNotFoundException(FlintException):
"""
Raised when no handler is registered for a given job type.
"""

def __init__(self, job_type: str) -> None:
super().__init__(
message=(
f"No handler registered for job type '{job_type}'. "
"Supported types: send_email, webhook_delivery, log_processing."
),
status_code=422,
)


class InvalidIntervalException(FlintException):
"""Raised when an interval string cannot be parsed."""

def __init__(self, interval: str) -> None:
super().__init__(
message=(
f"Invalid interval format: '{interval}'. "
"Use <number><unit> where unit is one of: s, m, h, d, mo, y. "
"Examples: '30s', '5m', '2h', '1d', '1mo', '1y'."
),
status_code=422,
)


class SettingNotFoundException(FlintException):
"""
Raised when a requested settings key does not exist.
"""

def __init__(self, key: str) -> None:
super().__init__(
message=f"Setting '{key}' not found.",
status_code=404,
)


class WorkerNotFoundException(FlintException):
"""Raised when a control action targets an unknown worker ID."""

def __init__(self, worker_id: str) -> None:
super().__init__(
message=f"Worker '{worker_id}' not found or is no longer active.",
status_code=404,
)
108 changes: 108 additions & 0 deletions app/core/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import logging
import os
import sys
from logging.handlers import RotatingFileHandler

import structlog


def setup_logging() -> None:
"""
Configure structlog for structured JSON logging.
Output goes to both stdout and a rotating log file.
Called once at application startup in app/main.py.
"""
from app.core.config import settings

log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)

log_dir = os.path.dirname(settings.LOG_FILE)
if log_dir:
os.makedirs(log_dir, exist_ok=True)

shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.dev.set_exc_info,
_scrub_sensitive_fields,
]

structlog.configure(
processors=shared_processors
+ [
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(log_level),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)

# File handler — rotating at 10MB, keeping 5 backups
file_handler = RotatingFileHandler(
settings.LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(log_level)
file_handler.setFormatter(logging.Formatter("%(message)s"))

# Stdout handler
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(log_level)
stdout_handler.setFormatter(logging.Formatter("%(message)s"))

# Root logger — captures structlog output and SQLAlchemy warnings
root_logger = logging.getLogger()
root_logger.setLevel(log_level)

# Avoid adding duplicate handlers on reload
if not root_logger.handlers:
root_logger.addHandler(stdout_handler)
root_logger.addHandler(file_handler)

# Quieten noisy third-party loggers
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("aiosmtplib").setLevel(logging.WARNING)


def _scrub_sensitive_fields(
logger: object,
method: str,
event_dict: dict,
) -> dict:
"""
Processor that removes sensitive keys from log entries
before they are written to stdout or file.
"""
_SENSITIVE_KEYS = frozenset(
{
"api_key",
"password",
"secret",
"token",
"smtp_password",
"authorization",
"x-api-key",
}
)
for key in list(event_dict.keys()):
if key.lower() in _SENSITIVE_KEYS:
event_dict[key] = "***REDACTED***"
return event_dict


def get_logger(name: str) -> structlog.BoundLogger:
"""
Returns a structlog bound logger for the given module name.
Usage:
from app.core.logger import get_logger
logger = get_logger(__name__)
logger.info("job_created", job_id=str(job.id), type=job.type)
"""
return structlog.get_logger(name)
15 changes: 15 additions & 0 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi import HTTPException, Security, status
from fastapi.security import APIKeyHeader

from app.core.config import settings

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


async def verify_api_key(api_key: str | None = Security(api_key_header)) -> str:
if not api_key or api_key != settings.API_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
)
return api_key
Loading
Loading