Skip to main content

featherbit/plugins/native/
error_handler.rs

1//! The `error-handler` node — turns accumulated gateway errors into a JSON
2//! error response. Typically wired to other nodes' error ports.
3//!
4//! ## Outcome exits pass through untouched
5//!
6//! A context that arrives with an **empty** `ctx.errors` is passed through
7//! unchanged: no template render, no status rewrite. That is the case for
8//! every *outcome* exit (`denied`, `limited`, `broken`, `abort`, `redirect`,
9//! `preflight`, `routed`, `hit`) — the emitting node did its job and already
10//! prepared the client-facing response, so there is no error record to report
11//! and nothing for this node to add. Without the guard, such a context would
12//! have its prepared body replaced by the raw, unsubstituted template (or by
13//! the default 500), which is precisely what the named-output-ports model
14//! exists to avoid. Outcome ports should normally be wired straight to
15//! `client`; routing one here is harmless but does nothing.
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use std::collections::HashMap;
20
21use crate::context::Context;
22use crate::plugins::{Plugin, PluginOutput, PluginResult};
23
24/// Overwrites `Context.response` with a configured status code and a body
25/// rendered from a template. The template may reference the most recent entry
26/// in `Context.errors` via `{{error.code}}`, `{{error.message}}`, and
27/// `{{error.node_id}}` placeholders; the content type is forced to
28/// `application/json`.
29pub struct ErrorHandlerPlugin {
30    status_code: u16,
31    body_template: String,
32}
33
34impl ErrorHandlerPlugin {
35    /// Builds the plugin from node config.
36    ///
37    /// Accepted keys (all optional; this constructor never errors):
38    /// - `status_code` (integer, default `500`): HTTP status of the error
39    ///   response.
40    /// - `body_template` (string, default a generic
41    ///   `{"error": "internal_error", ...}` JSON body): response body, with
42    ///   `{{error.code}}`, `{{error.message}}`, and `{{error.node_id}}`
43    ///   substituted from the last error in the context at execution time.
44    ///
45    /// ```yaml
46    /// type: error-handler
47    /// config:
48    ///   status_code: 502
49    ///   body_template: '{"error": "{{error.code}}", "message": "{{error.message}}"}'
50    /// ```
51    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        // No error to report — an outcome exit (denied/limited/broken/abort/
78        // redirect/preflight/routed/hit) or a plain success path. The response
79        // it carries is already prepared; leave it exactly as it is rather
80        // than clobbering it with an unrendered template or a default 500.
81        let Some(last_error) = ctx.errors.last() else {
82            return Ok(PluginOutput::success(ctx));
83        };
84
85        // Render the template using the last error in the context
86        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        // A prior node (e.g. `upstream` relaying a streamed response) may have
95        // left `response.stream` set. This node just overwrote `response.body`
96        // with a generated error body; leaving the stream in place would give
97        // the listener both set, and the documented invariant is that the
98        // stream wins — silently discarding this error response in favor of a
99        // half-finished upstream stream. Clear it so the generated body above
100        // is unambiguously what reaches the client.
101        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    //! featherbit-native plugin (no direct APISIX counterpart), so these derive
114    //! from featherbit's own spec: render the body template with the last error's
115    //! fields substituted, and apply the configured status code.
116    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        // No placeholders left unrendered.
179        assert!(!body.contains("{{"));
180    }
181
182    #[tokio::test]
183    async fn test_uses_the_last_error() {
184        // The template renders the most recent error, matching graph semantics
185        // where errors accumulate and the handler reports the latest.
186        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    /// An errorless context carrying an already-prepared response — what every
199    /// outcome exit (`denied`, `limited`, `broken`, ...) looks like — must pass
200    /// through completely untouched: same status, same body, same headers, and
201    /// no unrendered `{{error.code}}` placeholder anywhere.
202    #[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    /// The pass-through guard must not disturb the with-error behavior: a
241    /// context carrying an error still gets the configured status and the
242    /// rendered template.
243    #[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        // A stale prepared response must still be replaced when an error exists.
247        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    /// When this node overwrites `response.body` with a generated error body,
270    /// any `response.stream` set by an earlier node (e.g. `upstream` relaying
271    /// a partial streamed body) must be cleared. Otherwise `build_response`
272    /// would find both `body` and `stream` set and — per the documented
273    /// invariant that `stream` wins — silently discard the operator's error
274    /// response and send the half-finished upstream stream instead.
275    #[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}