1use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use serde::Serialize;
11
12#[derive(Debug, thiserror::Error)]
14pub enum ProxyError {
15 #[error("upstream request failed: {0}")]
18 Upstream(#[from] reqwest::Error),
19
20 #[error("failed to read request body")]
22 BadRequestBody,
23
24 #[error("{0}")]
26 BadRequest(String),
27
28 #[error("{0}")]
30 Engine(String),
31
32 #[error("{0}")]
34 NotFound(String),
35
36 #[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 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 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 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 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 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}