featherbit/plugins/native/
error_handler.rs1use async_trait::async_trait;
18use bytes::Bytes;
19use std::collections::HashMap;
20
21use crate::context::Context;
22use crate::plugins::{Plugin, PluginOutput, PluginResult};
23
24pub struct ErrorHandlerPlugin {
30 status_code: u16,
31 body_template: String,
32}
33
34impl ErrorHandlerPlugin {
35 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
52 let status_code = config
53 .get("status_code")
54 .and_then(|v| v.as_u64())
55 .unwrap_or(500) as u16;
56
57 let body_template = config
58 .get("body_template")
59 .and_then(|v| v.as_str())
60 .unwrap_or(r#"{"error": "internal_error", "message": "An unexpected error occurred"}"#)
61 .to_string();
62
63 Ok(Self {
64 status_code,
65 body_template,
66 })
67 }
68}
69
70#[async_trait]
71impl Plugin for ErrorHandlerPlugin {
72 fn plugin_type(&self) -> &str {
73 "error-handler"
74 }
75
76 async fn execute(&self, mut ctx: Context) -> PluginResult {
77 let Some(last_error) = ctx.errors.last() else {
82 return Ok(PluginOutput::success(ctx));
83 };
84
85 let body = self
87 .body_template
88 .replace("{{error.code}}", &last_error.code)
89 .replace("{{error.message}}", &last_error.message)
90 .replace("{{error.node_id}}", &last_error.node_id);
91
92 ctx.response.status_code = self.status_code;
93 ctx.response.body = Bytes::from(body);
94 ctx.response.stream = None;
102 ctx.response.headers.insert(
103 "content-type".to_string(),
104 vec!["application/json".to_string()],
105 );
106
107 Ok(PluginOutput::success(ctx))
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
117 use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
118
119 fn ctx_with_error(err: Option<GatewayError>) -> Context {
120 Context {
121 request: GatewayRequest {
122 method: "GET".to_string(),
123 path: "/hello".to_string(),
124 host: "h".to_string(),
125 scheme: "http".to_string(),
126 headers: HashMap::new(),
127 query_params: HashMap::new(),
128 body: Bytes::new(),
129 remote_addr: "1.2.3.4:5".to_string(),
130 protocol: Protocol::Http1,
131 },
132 response: GatewayResponse {
133 status_code: 0,
134 headers: HashMap::new(),
135 body: Bytes::new(),
136 stream: None,
137 },
138 message: HashMap::new(),
139 errors: err.into_iter().collect(),
140 }
141 }
142
143 fn err(code: &str, message: &str, node_id: &str) -> GatewayError {
144 GatewayError {
145 node_id: node_id.to_string(),
146 code: code.to_string(),
147 message: message.to_string(),
148 metadata: HashMap::new(),
149 }
150 }
151
152 fn plugin(config: serde_json::Value) -> ErrorHandlerPlugin {
153 let map: HashMap<String, serde_json::Value> =
154 config.as_object().unwrap().clone().into_iter().collect();
155 ErrorHandlerPlugin::from_config(&map).unwrap()
156 }
157
158 #[tokio::test]
159 async fn test_renders_error_fields_and_status() {
160 let p = plugin(serde_json::json!({
161 "status_code": 502,
162 "body_template": "{\"code\":\"{{error.code}}\",\"msg\":\"{{error.message}}\",\"node\":\"{{error.node_id}}\"}"
163 }));
164 let out = p
165 .execute(ctx_with_error(Some(err(
166 "UPSTREAM_ERROR",
167 "connection refused",
168 "backend",
169 ))))
170 .await
171 .unwrap();
172
173 assert_eq!(out.context.response.status_code, 502);
174 let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
175 assert!(body.contains("UPSTREAM_ERROR"));
176 assert!(body.contains("connection refused"));
177 assert!(body.contains("backend"));
178 assert!(!body.contains("{{"));
180 }
181
182 #[tokio::test]
183 async fn test_uses_the_last_error() {
184 let mut ctx = ctx_with_error(Some(err("FIRST", "first", "a")));
187 ctx.errors.push(err("SECOND", "second", "b"));
188 let out = plugin(serde_json::json!({ "body_template": "{{error.code}}" }))
189 .execute(ctx)
190 .await
191 .unwrap();
192 assert_eq!(
193 String::from_utf8(out.context.response.body.to_vec()).unwrap(),
194 "SECOND"
195 );
196 }
197
198 #[tokio::test]
203 async fn test_errorless_prepared_response_passes_through_intact() {
204 let mut ctx = ctx_with_error(None);
205 ctx.response.status_code = 401;
206 ctx.response.body = Bytes::from(r#"{"error":"unauthorized"}"#);
207 ctx.response.headers.insert(
208 "www-authenticate".to_string(),
209 vec!["Basic realm=\"api\"".to_string()],
210 );
211 ctx.response.headers.insert(
212 "content-type".to_string(),
213 vec!["application/json".to_string()],
214 );
215
216 let out = plugin(serde_json::json!({
217 "status_code": 500,
218 "body_template": "{{error.code}}"
219 }))
220 .execute(ctx)
221 .await
222 .unwrap();
223
224 assert_eq!(out.port, None, "still exits on the success port");
225 assert_eq!(out.context.response.status_code, 401);
226 assert_eq!(
227 String::from_utf8(out.context.response.body.to_vec()).unwrap(),
228 r#"{"error":"unauthorized"}"#
229 );
230 assert_eq!(
231 out.context
232 .response
233 .headers
234 .get("www-authenticate")
235 .unwrap()[0],
236 "Basic realm=\"api\""
237 );
238 }
239
240 #[tokio::test]
244 async fn test_with_error_behavior_unchanged_by_passthrough_guard() {
245 let mut ctx = ctx_with_error(Some(err("UPSTREAM_ERROR", "refused", "backend")));
246 ctx.response.status_code = 200;
248 ctx.response.body = Bytes::from("stale");
249
250 let out = plugin(serde_json::json!({
251 "status_code": 502,
252 "body_template": "{\"code\":\"{{error.code}}\"}"
253 }))
254 .execute(ctx)
255 .await
256 .unwrap();
257
258 assert_eq!(out.context.response.status_code, 502);
259 assert_eq!(
260 String::from_utf8(out.context.response.body.to_vec()).unwrap(),
261 r#"{"code":"UPSTREAM_ERROR"}"#
262 );
263 assert_eq!(
264 out.context.response.headers.get("content-type").unwrap()[0],
265 "application/json"
266 );
267 }
268
269 #[tokio::test]
276 async fn test_clears_stream_when_error_body_is_generated() {
277 use crate::context::stream::ResponseStream;
278 use http_body_util::{BodyExt, Full};
279
280 let mut ctx = ctx_with_error(Some(err("UPSTREAM_ERROR", "refused", "backend")));
281 let boxed = Full::new(Bytes::from_static(b"partial-stream-bytes"))
282 .map_err(|never| match never {})
283 .boxed();
284 ctx.response.stream = Some(ResponseStream::new(boxed));
285
286 let out = plugin(serde_json::json!({
287 "status_code": 502,
288 "body_template": "{\"code\":\"{{error.code}}\"}"
289 }))
290 .execute(ctx)
291 .await
292 .unwrap();
293
294 assert!(
295 out.context.response.stream.is_none(),
296 "generated error body must not coexist with a stale stream"
297 );
298 assert_eq!(
299 String::from_utf8(out.context.response.body.to_vec()).unwrap(),
300 r#"{"code":"UPSTREAM_ERROR"}"#
301 );
302 }
303}