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<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(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}