use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum ProxyError {
#[error("upstream request failed: {0}")]
Upstream(#[from] reqwest::Error),
#[error("failed to read request body")]
BadRequestBody,
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Engine(String),
#[error("{0}")]
NotFound(String),
#[error("internal error")]
Internal(String),
}
impl ProxyError {
fn kind(&self) -> &'static str {
match self {
ProxyError::Upstream(_) => "upstream_error",
ProxyError::BadRequestBody | ProxyError::BadRequest(_) => "bad_request",
ProxyError::Engine(_) => "engine_error",
ProxyError::NotFound(_) => "not_found",
ProxyError::Internal(_) => "internal_error",
}
}
fn status(&self) -> StatusCode {
match self {
ProxyError::Upstream(_) | ProxyError::Engine(_) => StatusCode::BAD_GATEWAY,
ProxyError::BadRequestBody | ProxyError::BadRequest(_) => StatusCode::BAD_REQUEST,
ProxyError::NotFound(_) => StatusCode::NOT_FOUND,
ProxyError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn client_message(&self) -> String {
match self {
ProxyError::BadRequest(m) | ProxyError::NotFound(m) => m.clone(),
ProxyError::BadRequestBody => "failed to read request body".to_owned(),
ProxyError::Upstream(_) => "upstream request failed".to_owned(),
ProxyError::Engine(_) => "no rung could serve a valid response".to_owned(),
ProxyError::Internal(_) => "internal error".to_owned(),
}
}
fn detail_is_internal(&self) -> bool {
matches!(
self,
ProxyError::Upstream(_) | ProxyError::Engine(_) | ProxyError::Internal(_)
)
}
}
#[derive(Serialize)]
struct ErrorBody<'a> {
error: ErrorDetail<'a>,
}
#[derive(Serialize)]
struct ErrorDetail<'a> {
#[serde(rename = "type")]
kind: &'a str,
message: &'a str,
}
impl IntoResponse for ProxyError {
fn into_response(self) -> Response {
let status = self.status();
let kind = self.kind();
if self.detail_is_internal() {
tracing::warn!(kind, detail = %self, "request error");
}
let message = self.client_message();
let body = ErrorBody {
error: ErrorDetail {
kind,
message: &message,
},
};
(status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bad_request_body_maps_to_400_with_structured_json() {
let response = ProxyError::BadRequestBody.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["type"], "bad_request");
}
#[tokio::test]
async fn engine_error_does_not_leak_internal_detail_to_caller() {
let leaky = "connection refused to https://internal-upstream.acme:8443/v1/messages";
let response = ProxyError::Engine(leaky.to_owned()).into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["type"], "engine_error");
let msg = json["error"]["message"].as_str().unwrap();
assert!(
!msg.contains("internal-upstream"),
"leaked upstream host: {msg}"
);
assert!(!msg.contains("acme"), "leaked internal detail: {msg}");
}
#[tokio::test]
async fn bad_request_keeps_its_client_actionable_message() {
let response =
ProxyError::BadRequest("field `model` is required".to_owned()).into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"]["message"].as_str().unwrap().contains("model"));
}
}