rust-job-queue-api-worker-system 0.1.0

A production-shaped Rust job queue: Axum API + async workers + Postgres SKIP LOCKED dequeue, retries with decorrelated jitter, idempotency, cooperative cancellation, OpenAPI, Prometheus metrics.
//! HTTP-layer error type and the mapping from internal `JobError`s to
//! HTTP responses.
//!
//! Two principles drive this file:
//!
//! 1. **Redacted bodies for 5xx, full detail in tracing.** Internal
//!    errors (DB failures, programming bugs) emit a generic
//!    `{"error":"internal","message":"internal server error"}` to the
//!    client and log the actual error via `tracing::error!`. Clients
//!    never see stack traces or query text; operators searching logs by
//!    `request_id` do.
//! 2. **Domain errors get specific HTTP status codes.** `NotFound → 404`,
//!    `InvalidTransition → 409`, `PayloadInvalid → 422`,
//!    `IdempotencyConflict → 409`. The mapping is in `From<JobError>
//!    for ApiError` and is the only place anything has to change if a
//!    new domain error variant is introduced.

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use thiserror::Error;

use crate::api::dto::ErrorBody;
use crate::error::JobError;

/// Error type returned by HTTP handlers in this crate.
///
/// Each variant maps to one HTTP status code in [`ApiError::into_response`].
/// Handlers should return `Result<T, ApiError>`; Axum's
/// `IntoResponse` machinery converts the `Err` arm into the response
/// automatically.
#[derive(Debug, Error)]
pub enum ApiError {
    /// 404 Not Found. No resource matched the request.
    #[error("not found")]
    NotFound,

    /// 400 Bad Request. The request itself was malformed (missing
    /// required header, malformed body, etc.).
    #[error("bad request: {0}")]
    BadRequest(String),

    /// 409 Conflict. The request was well-formed but conflicts with the
    /// current state of the resource (e.g., cancelling a terminal job,
    /// idempotency-key collision with a different request shape).
    #[error("conflict: {0}")]
    Conflict(String),

    /// 422 Unprocessable Entity. The request body parsed but failed
    /// semantic validation (e.g., a `send_email` payload missing the
    /// `body` field).
    #[error("validation: {0}")]
    Unprocessable(String),

    /// 500 Internal Server Error. Everything that doesn't fit the
    /// 4xx categories. The wrapped error is logged with full detail; the
    /// response body is redacted.
    #[error(transparent)]
    Internal(#[from] anyhow::Error),
}

/// Map domain errors from [`crate::queue`] into HTTP-shape errors. The
/// mapping is the single source of truth for "this internal error means
/// this HTTP status".
impl From<JobError> for ApiError {
    fn from(e: JobError) -> Self {
        match e {
            JobError::NotFound(_) => ApiError::NotFound,
            JobError::InvalidTransition { .. } => ApiError::Conflict(e.to_string()),
            JobError::PayloadInvalid { .. } => ApiError::Unprocessable(e.to_string()),
            JobError::IdempotencyConflict(_) => ApiError::Conflict(e.to_string()),
            // DB errors are always "something is wrong on our side";
            // expose nothing to the client beyond "internal error" but
            // log the underlying sqlx::Error.
            JobError::Db(err) => ApiError::Internal(anyhow::anyhow!(err)),
            // Serde errors at this layer usually mean the payload had
            // the right top-level shape but a nested field had the
            // wrong type. Treat as a validation issue.
            JobError::Serde(err) => ApiError::Unprocessable(err.to_string()),
        }
    }
}

/// Direct conversion for the cases where a handler calls `sqlx` outside
/// of `crate::queue` (e.g., `SELECT 1` in `/health`). Routes through
/// `ApiError::Internal` so the response is redacted and the original
/// error is logged.
impl From<sqlx::Error> for ApiError {
    fn from(e: sqlx::Error) -> Self {
        ApiError::Internal(anyhow::anyhow!(e))
    }
}

/// Render an `ApiError` as the actual HTTP response.
///
/// For 4xx codes the body carries a useful `message` so the client can
/// debug. For 5xx the body is intentionally generic (`"internal server
/// error"`) and the real error is logged at `error!` level with the
/// request's tracing span. Anyone debugging an incident finds the
/// detailed error in logs keyed on `request_id`.
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, slug, message) = match self {
            ApiError::NotFound => (
                StatusCode::NOT_FOUND,
                "not_found",
                "resource not found".into(),
            ),
            ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m),
            ApiError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m),
            ApiError::Unprocessable(m) => (StatusCode::UNPROCESSABLE_ENTITY, "validation", m),
            ApiError::Internal(err) => {
                // Full detail to logs. The current tracing span (created
                // by the `TraceLayer` middleware in `app.rs`) carries
                // the request_id; this log line inherits it.
                tracing::error!(error = %err, "internal server error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "internal",
                    "internal server error".into(),
                )
            }
        };
        (
            status,
            Json(ErrorBody {
                error: slug,
                message,
            }),
        )
            .into_response()
    }
}