Skip to main content

embacle_server/uhp/
error.rs

1// ABOUTME: The UHP structured error envelope, its type/code taxonomy, and HTTP status mapping
2// ABOUTME: Every UHP route answers failures with this shape; messages never carry internals
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Errors, in the shape the protocol requires.
8//!
9//! UHP does not accept a bare string or a foreign envelope: a failure is
10//! `{"error": {"type", "code", "message"}}`, where `type` is one of six
11//! categories and `code` is machine-readable. Clients branch on `code`, so an
12//! unknown harness must say `harness_not_found` and nothing else.
13//!
14//! `message` is written for a human and MUST NOT leak internals — the
15//! conformance suite greps it for `Traceback`, `File "/` and stack-frame
16//! markers, and a server that formats a `Debug` error into it fails.
17
18use axum::http::StatusCode;
19use axum::response::{IntoResponse, Response};
20use axum::Json;
21use serde::Serialize;
22use serde_json::Value;
23
24/// The six error categories the specification enumerates.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ErrorType {
28    /// The request itself was malformed or asked for something impossible.
29    InvalidRequestError,
30    /// No credential, or one the server does not accept.
31    AuthenticationError,
32    /// Authenticated, but not allowed to do this.
33    PermissionError,
34    /// Too many requests.
35    RateLimitError,
36    /// The harness ran and failed.
37    HarnessError,
38    /// Anything the server got wrong.
39    ServerError,
40}
41
42impl ErrorType {
43    /// The status this category answers with when nothing more specific applies.
44    const fn status(self) -> StatusCode {
45        match self {
46            Self::InvalidRequestError => StatusCode::BAD_REQUEST,
47            Self::AuthenticationError => StatusCode::UNAUTHORIZED,
48            Self::PermissionError => StatusCode::FORBIDDEN,
49            Self::RateLimitError => StatusCode::TOO_MANY_REQUESTS,
50            Self::HarnessError | Self::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
51        }
52    }
53}
54
55/// The body of a UHP failure.
56#[derive(Debug, Clone, Serialize)]
57pub struct UhpError {
58    /// Broad category.
59    #[serde(rename = "type")]
60    pub kind: ErrorType,
61    /// Machine-readable specific condition, e.g. `harness_not_found`.
62    pub code: String,
63    /// Human-readable, and free of stack traces or internal paths.
64    pub message: String,
65    /// Structured extra information, e.g. the versions a server does support.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub detail: Option<Box<Value>>,
68}
69
70/// The wire envelope: an error is always nested under `error`.
71#[derive(Debug, Clone, Serialize)]
72pub struct ErrorEnvelope {
73    /// The failure itself.
74    pub error: UhpError,
75}
76
77/// A UHP failure together with the status it answers with.
78#[derive(Debug, Clone)]
79pub struct UhpFailure {
80    status: StatusCode,
81    error: UhpError,
82}
83
84impl UhpFailure {
85    /// Build a failure, taking the status from the category.
86    pub fn new(kind: ErrorType, code: &str, message: impl Into<String>) -> Self {
87        Self {
88            status: kind.status(),
89            error: UhpError {
90                kind,
91                code: code.to_owned(),
92                message: message.into(),
93                detail: None,
94            },
95        }
96    }
97
98    /// Override the status where the specification names a different one.
99    #[must_use]
100    pub const fn with_status(mut self, status: StatusCode) -> Self {
101        self.status = status;
102        self
103    }
104
105    /// Attach structured detail, such as the list of supported versions.
106    #[must_use]
107    pub fn with_detail(mut self, detail: Value) -> Self {
108        self.error.detail = Some(Box::new(detail));
109        self
110    }
111
112    /// No credential presented, or one the server does not accept.
113    pub fn unauthorized(code: &str, message: impl Into<String>) -> Self {
114        Self::new(ErrorType::AuthenticationError, code, message)
115    }
116
117    /// A harness id that names nothing this server runs.
118    ///
119    /// 404 rather than the category default, because the specification maps
120    /// every `*_not_found` code to a not-found status.
121    pub fn not_found(code: &str, message: impl Into<String>) -> Self {
122        Self::new(ErrorType::InvalidRequestError, code, message).with_status(StatusCode::NOT_FOUND)
123    }
124}
125
126impl UhpFailure {
127    /// The error body, for embedding in a `failed` response object.
128    #[must_use]
129    pub fn into_error(self) -> UhpError {
130        self.error
131    }
132}
133
134impl IntoResponse for UhpFailure {
135    fn into_response(self) -> Response {
136        (self.status, Json(ErrorEnvelope { error: self.error })).into_response()
137    }
138}