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
4use async_trait::async_trait;
5use bytes::Bytes;
6use std::collections::HashMap;
7
8use crate::context::Context;
9use crate::plugins::{Plugin, PluginOutput, PluginResult};
10
11/// Overwrites `Context.response` with a configured status code and a body
12/// rendered from a template. The template may reference the most recent entry
13/// in `Context.errors` via `{{error.code}}`, `{{error.message}}`, and
14/// `{{error.node_id}}` placeholders; the content type is forced to
15/// `application/json`.
16pub struct ErrorHandlerPlugin {
17    status_code: u16,
18    body_template: String,
19}
20
21impl ErrorHandlerPlugin {
22    /// Builds the plugin from node config.
23    ///
24    /// Accepted keys (all optional; this constructor never errors):
25    /// - `status_code` (integer, default `500`): HTTP status of the error
26    ///   response.
27    /// - `body_template` (string, default a generic
28    ///   `{"error": "internal_error", ...}` JSON body): response body, with
29    ///   `{{error.code}}`, `{{error.message}}`, and `{{error.node_id}}`
30    ///   substituted from the last error in the context at execution time.
31    ///
32    /// ```yaml
33    /// type: error-handler
34    /// config:
35    ///   status_code: 502
36    ///   body_template: '{"error": "{{error.code}}", "message": "{{error.message}}"}'
37    /// ```
38    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
39        let status_code = config
40            .get("status_code")
41            .and_then(|v| v.as_u64())
42            .unwrap_or(500) as u16;
43
44        let body_template = config
45            .get("body_template")
46            .and_then(|v| v.as_str())
47            .unwrap_or(r#"{"error": "internal_error", "message": "An unexpected error occurred"}"#)
48            .to_string();
49
50        Ok(Self {
51            status_code,
52            body_template,
53        })
54    }
55}
56
57#[async_trait]
58impl Plugin for ErrorHandlerPlugin {
59    fn plugin_type(&self) -> &str {
60        "error-handler"
61    }
62
63    async fn execute(
64        &self,
65        mut ctx: Context,
66        _named_inputs: &HashMap<String, serde_json::Value>,
67    ) -> PluginResult {
68        // Render the template using the last error in the context
69        let body = if let Some(last_error) = ctx.errors.last() {
70            self.body_template
71                .replace("{{error.code}}", &last_error.code)
72                .replace("{{error.message}}", &last_error.message)
73                .replace("{{error.node_id}}", &last_error.node_id)
74        } else {
75            self.body_template.clone()
76        };
77
78        ctx.response.status_code = self.status_code;
79        ctx.response.body = Bytes::from(body);
80        ctx.response.headers.insert(
81            "content-type".to_string(),
82            vec!["application/json".to_string()],
83        );
84
85        Ok(PluginOutput {
86            context: ctx,
87            named_outputs: HashMap::new(),
88        })
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    //! featherbit-native plugin (no direct APISIX counterpart), so these derive
95    //! from featherbit's own spec: render the body template with the last error's
96    //! fields substituted, and apply the configured status code.
97    use super::*;
98    use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
99
100    fn ctx_with_error(err: Option<GatewayError>) -> Context {
101        Context {
102            request: GatewayRequest {
103                method: "GET".to_string(),
104                path: "/hello".to_string(),
105                host: "h".to_string(),
106                scheme: "http".to_string(),
107                headers: HashMap::new(),
108                query_params: HashMap::new(),
109                body: Bytes::new(),
110                remote_addr: "1.2.3.4:5".to_string(),
111                protocol: Protocol::Http1,
112            },
113            response: GatewayResponse {
114                status_code: 0,
115                headers: HashMap::new(),
116                body: Bytes::new(),
117            },
118            message: HashMap::new(),
119            errors: err.into_iter().collect(),
120        }
121    }
122
123    fn err(code: &str, message: &str, node_id: &str) -> GatewayError {
124        GatewayError {
125            node_id: node_id.to_string(),
126            code: code.to_string(),
127            message: message.to_string(),
128            metadata: HashMap::new(),
129        }
130    }
131
132    fn plugin(config: serde_json::Value) -> ErrorHandlerPlugin {
133        let map: HashMap<String, serde_json::Value> =
134            config.as_object().unwrap().clone().into_iter().collect();
135        ErrorHandlerPlugin::from_config(&map).unwrap()
136    }
137
138    #[tokio::test]
139    async fn test_renders_error_fields_and_status() {
140        let p = plugin(serde_json::json!({
141            "status_code": 502,
142            "body_template": "{\"code\":\"{{error.code}}\",\"msg\":\"{{error.message}}\",\"node\":\"{{error.node_id}}\"}"
143        }));
144        let out = p
145            .execute(
146                ctx_with_error(Some(err("UPSTREAM_ERROR", "connection refused", "backend"))),
147                &HashMap::new(),
148            )
149            .await
150            .unwrap();
151
152        assert_eq!(out.context.response.status_code, 502);
153        let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
154        assert!(body.contains("UPSTREAM_ERROR"));
155        assert!(body.contains("connection refused"));
156        assert!(body.contains("backend"));
157        // No placeholders left unrendered.
158        assert!(!body.contains("{{"));
159    }
160
161    #[tokio::test]
162    async fn test_uses_the_last_error() {
163        // The template renders the most recent error, matching graph semantics
164        // where errors accumulate and the handler reports the latest.
165        let mut ctx = ctx_with_error(Some(err("FIRST", "first", "a")));
166        ctx.errors.push(err("SECOND", "second", "b"));
167        let out = plugin(serde_json::json!({ "body_template": "{{error.code}}" }))
168            .execute(ctx, &HashMap::new())
169            .await
170            .unwrap();
171        assert_eq!(
172            String::from_utf8(out.context.response.body.to_vec()).unwrap(),
173            "SECOND"
174        );
175    }
176
177    #[tokio::test]
178    async fn test_no_error_leaves_template_literal() {
179        let out = plugin(serde_json::json!({ "body_template": "{{error.code}}" }))
180            .execute(ctx_with_error(None), &HashMap::new())
181            .await
182            .unwrap();
183        // With no error to substitute, the raw template is emitted unchanged.
184        assert_eq!(
185            String::from_utf8(out.context.response.body.to_vec()).unwrap(),
186            "{{error.code}}"
187        );
188    }
189}