Skip to main content

firstpass_proxy/
error.rs

1//! Structured, no-leak errors (SPEC §7.4: "errors are structured, never prose an agent
2//! must parse").
3//!
4//! Every error the proxy returns to a caller is `{"error": {"type": "...", "message": "..."}}`.
5//! Nothing here ever includes a stack trace, an API key, or raw prompt text.
6
7use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use serde::Serialize;
11
12/// Errors that can surface on the request path.
13#[derive(Debug, thiserror::Error)]
14pub enum ProxyError {
15    /// The upstream provider could not be reached, or returned something unusable
16    /// (connection failure, timeout, decode error).
17    #[error("upstream request failed: {0}")]
18    Upstream(#[from] reqwest::Error),
19
20    /// The inbound request body could not be read (e.g. the connection dropped mid-body).
21    #[error("failed to read request body")]
22    BadRequestBody,
23
24    /// The request body was malformed for the target wire API (enforce mode parses it).
25    #[error("{0}")]
26    BadRequest(String),
27
28    /// The escalation engine could not serve any output (every rung errored).
29    #[error("{0}")]
30    Engine(String),
31
32    /// A referenced resource does not exist (e.g. feedback for an unknown trace id).
33    #[error("{0}")]
34    NotFound(String),
35
36    /// Authentication failed: a missing or invalid tenant API key when `require_auth` is on
37    /// (ADR 0004 §D1). The body is intentionally opaque — no "unknown tenant" oracle.
38    #[error("unauthorized")]
39    Unauthorized,
40
41    /// An internal failure (e.g. the trace store errored). Never leaks internals to the caller.
42    #[error("internal error")]
43    Internal(String),
44
45    /// The tenant exceeded its configured request rate (ADR 0004 §D6). The body is intentionally
46    /// opaque — no bucket state or limit value is disclosed.
47    #[error("rate limited")]
48    RateLimited,
49}
50
51impl ProxyError {
52    fn kind(&self) -> &'static str {
53        match self {
54            ProxyError::Upstream(_) => "upstream_error",
55            ProxyError::BadRequestBody | ProxyError::BadRequest(_) => "bad_request",
56            ProxyError::Engine(_) => "engine_error",
57            ProxyError::NotFound(_) => "not_found",
58            ProxyError::Unauthorized => "unauthorized",
59            ProxyError::Internal(_) => "internal_error",
60            ProxyError::RateLimited => "rate_limited",
61        }
62    }
63
64    fn status(&self) -> StatusCode {
65        match self {
66            ProxyError::Upstream(_) | ProxyError::Engine(_) => StatusCode::BAD_GATEWAY,
67            ProxyError::BadRequestBody | ProxyError::BadRequest(_) => StatusCode::BAD_REQUEST,
68            ProxyError::NotFound(_) => StatusCode::NOT_FOUND,
69            ProxyError::Unauthorized => StatusCode::UNAUTHORIZED,
70            ProxyError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
71            ProxyError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
72        }
73    }
74
75    /// The message returned to the caller. Client-actionable errors (validation, not-found) return
76    /// their real message; upstream / engine / internal detail is generalized so the response never
77    /// leaks the upstream's identity, an internal error string, or server state (the full detail is
78    /// logged server-side instead — see [`IntoResponse`]).
79    fn client_message(&self) -> String {
80        match self {
81            ProxyError::BadRequest(m) | ProxyError::NotFound(m) => m.clone(),
82            ProxyError::BadRequestBody => "failed to read request body".to_owned(),
83            ProxyError::Upstream(_) => "upstream request failed".to_owned(),
84            ProxyError::Engine(_) => "no rung could serve a valid response".to_owned(),
85            ProxyError::Unauthorized => "unauthorized".to_owned(),
86            ProxyError::Internal(_) => "internal error".to_owned(),
87            ProxyError::RateLimited => "rate limited".to_owned(),
88        }
89    }
90
91    /// Whether the full detail must be kept server-side (logged, not returned).
92    fn detail_is_internal(&self) -> bool {
93        matches!(
94            self,
95            ProxyError::Upstream(_) | ProxyError::Engine(_) | ProxyError::Internal(_)
96        )
97    }
98}
99
100#[derive(Serialize)]
101struct ErrorBody<'a> {
102    error: ErrorDetail<'a>,
103}
104
105#[derive(Serialize)]
106struct ErrorDetail<'a> {
107    #[serde(rename = "type")]
108    kind: &'a str,
109    message: &'a str,
110}
111
112impl IntoResponse for ProxyError {
113    fn into_response(self) -> Response {
114        let status = self.status();
115        let kind = self.kind();
116        // Log the full detail server-side for the variants whose detail must not reach the caller,
117        // so operators keep the diagnostic while clients get an opaque message.
118        if self.detail_is_internal() {
119            tracing::warn!(kind, detail = %self, "request error");
120        }
121        let message = self.client_message();
122        let body = ErrorBody {
123            error: ErrorDetail {
124                kind,
125                message: &message,
126            },
127        };
128        (status, Json(body)).into_response()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[tokio::test]
137    async fn bad_request_body_maps_to_400_with_structured_json() {
138        let response = ProxyError::BadRequestBody.into_response();
139        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
140        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
141            .await
142            .unwrap();
143        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
144        assert_eq!(json["error"]["type"], "bad_request");
145    }
146
147    #[tokio::test]
148    async fn engine_error_does_not_leak_internal_detail_to_caller() {
149        // The engine detail names a specific upstream host — it must not reach the client body.
150        let leaky = "connection refused to https://internal-upstream.acme:8443/v1/messages";
151        let response = ProxyError::Engine(leaky.to_owned()).into_response();
152        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
153        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
154            .await
155            .unwrap();
156        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
157        assert_eq!(json["error"]["type"], "engine_error");
158        let msg = json["error"]["message"].as_str().unwrap();
159        assert!(
160            !msg.contains("internal-upstream"),
161            "leaked upstream host: {msg}"
162        );
163        assert!(!msg.contains("acme"), "leaked internal detail: {msg}");
164    }
165
166    #[tokio::test]
167    async fn bad_request_keeps_its_client_actionable_message() {
168        // Validation messages ARE client-actionable and should be preserved.
169        let response =
170            ProxyError::BadRequest("field `model` is required".to_owned()).into_response();
171        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
172            .await
173            .unwrap();
174        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
175        assert!(json["error"]["message"].as_str().unwrap().contains("model"));
176    }
177}