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    /// An internal failure (e.g. the trace store errored). Never leaks internals to the caller.
37    #[error("internal error")]
38    Internal(String),
39}
40
41impl ProxyError {
42    fn kind(&self) -> &'static str {
43        match self {
44            ProxyError::Upstream(_) => "upstream_error",
45            ProxyError::BadRequestBody | ProxyError::BadRequest(_) => "bad_request",
46            ProxyError::Engine(_) => "engine_error",
47            ProxyError::NotFound(_) => "not_found",
48            ProxyError::Internal(_) => "internal_error",
49        }
50    }
51
52    fn status(&self) -> StatusCode {
53        match self {
54            ProxyError::Upstream(_) | ProxyError::Engine(_) => StatusCode::BAD_GATEWAY,
55            ProxyError::BadRequestBody | ProxyError::BadRequest(_) => StatusCode::BAD_REQUEST,
56            ProxyError::NotFound(_) => StatusCode::NOT_FOUND,
57            ProxyError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
58        }
59    }
60
61    /// The message returned to the caller. Client-actionable errors (validation, not-found) return
62    /// their real message; upstream / engine / internal detail is generalized so the response never
63    /// leaks the upstream's identity, an internal error string, or server state (the full detail is
64    /// logged server-side instead — see [`IntoResponse`]).
65    fn client_message(&self) -> String {
66        match self {
67            ProxyError::BadRequest(m) | ProxyError::NotFound(m) => m.clone(),
68            ProxyError::BadRequestBody => "failed to read request body".to_owned(),
69            ProxyError::Upstream(_) => "upstream request failed".to_owned(),
70            ProxyError::Engine(_) => "no rung could serve a valid response".to_owned(),
71            ProxyError::Internal(_) => "internal error".to_owned(),
72        }
73    }
74
75    /// Whether the full detail must be kept server-side (logged, not returned).
76    fn detail_is_internal(&self) -> bool {
77        matches!(
78            self,
79            ProxyError::Upstream(_) | ProxyError::Engine(_) | ProxyError::Internal(_)
80        )
81    }
82}
83
84#[derive(Serialize)]
85struct ErrorBody<'a> {
86    error: ErrorDetail<'a>,
87}
88
89#[derive(Serialize)]
90struct ErrorDetail<'a> {
91    #[serde(rename = "type")]
92    kind: &'a str,
93    message: &'a str,
94}
95
96impl IntoResponse for ProxyError {
97    fn into_response(self) -> Response {
98        let status = self.status();
99        let kind = self.kind();
100        // Log the full detail server-side for the variants whose detail must not reach the caller,
101        // so operators keep the diagnostic while clients get an opaque message.
102        if self.detail_is_internal() {
103            tracing::warn!(kind, detail = %self, "request error");
104        }
105        let message = self.client_message();
106        let body = ErrorBody {
107            error: ErrorDetail {
108                kind,
109                message: &message,
110            },
111        };
112        (status, Json(body)).into_response()
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[tokio::test]
121    async fn bad_request_body_maps_to_400_with_structured_json() {
122        let response = ProxyError::BadRequestBody.into_response();
123        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
124        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
125            .await
126            .unwrap();
127        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
128        assert_eq!(json["error"]["type"], "bad_request");
129    }
130
131    #[tokio::test]
132    async fn engine_error_does_not_leak_internal_detail_to_caller() {
133        // The engine detail names a specific upstream host — it must not reach the client body.
134        let leaky = "connection refused to https://internal-upstream.acme:8443/v1/messages";
135        let response = ProxyError::Engine(leaky.to_owned()).into_response();
136        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
137        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
138            .await
139            .unwrap();
140        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
141        assert_eq!(json["error"]["type"], "engine_error");
142        let msg = json["error"]["message"].as_str().unwrap();
143        assert!(
144            !msg.contains("internal-upstream"),
145            "leaked upstream host: {msg}"
146        );
147        assert!(!msg.contains("acme"), "leaked internal detail: {msg}");
148    }
149
150    #[tokio::test]
151    async fn bad_request_keeps_its_client_actionable_message() {
152        // Validation messages ARE client-actionable and should be preserved.
153        let response =
154            ProxyError::BadRequest("field `model` is required".to_owned()).into_response();
155        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
156            .await
157            .unwrap();
158        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
159        assert!(json["error"]["message"].as_str().unwrap().contains("model"));
160    }
161}