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("unauthorized")]
39 Unauthorized,
40
41 #[error("internal error")]
43 Internal(String),
44
45 #[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 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 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 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 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 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}