Skip to main content

featherbit/plugins/native/
error_page.rs

1//! The `error-page` node — replaces the body and content type of
2//! gateway-generated error responses (404, 500, 502, 503) with configured
3//! pages. Port of APISIX's `error-page` plugin (response-phase: place after
4//! `upstream`, before `client`).
5//!
6//! Deviations from APISIX:
7//! - APISIX configures the pages via plugin *metadata* (with an `enable`
8//!   flag); featherbit has no metadata tier, so the pages live directly in
9//!   the node config and placing the node in the graph is the enable switch.
10//! - APISIX only intercepts responses whose source is not the upstream
11//!   (`get_response_source(ctx) ~= "upstream"`). featherbit's equivalent
12//!   heuristic is `Context.errors` being non-empty — a response produced by
13//!   the gateway (error-handler, auth rejection, ...) always carries the
14//!   error record that routed it there, while a clean upstream response does
15//!   not. An upstream's own 502 therefore passes through untouched, exactly
16//!   as in APISIX.
17
18use async_trait::async_trait;
19use bytes::Bytes;
20use std::collections::HashMap;
21
22use crate::context::Context;
23use crate::plugins::{Plugin, PluginOutput, PluginResult};
24
25/// The status codes APISIX's error-page supports (its metadata schema
26/// hardcodes `error_404` / `error_500` / `error_502` / `error_503`).
27const SUPPORTED_STATUS: [(u16, &str); 4] = [
28    (404, "404 Not Found"),
29    (500, "500 Internal Server Error"),
30    (502, "502 Bad Gateway"),
31    (503, "503 Service Unavailable"),
32];
33
34/// One configured error page.
35struct ErrorPage {
36    body: Bytes,
37    content_type: String,
38}
39
40/// Replaces `Context.response.body` and `content-type` when the response was
41/// generated by the gateway (heuristic: `Context.errors` non-empty) and its
42/// status code has a configured page. All other responses pass through
43/// unchanged. This plugin never fails at execution time.
44pub struct ErrorPagePlugin {
45    pages: HashMap<u16, ErrorPage>,
46}
47
48/// The default page body, mirroring APISIX's `err_body` template (with the
49/// gateway's own name in the footer).
50fn default_body(title: &str) -> String {
51    format!(
52        "<html>\n<head><title>{title}</title></head>\n<body>\n\
53         <center><h1>{title}</h1></center>\n\
54         <hr><center>featherbit</center>\n</body>\n</html>"
55    )
56}
57
58impl ErrorPagePlugin {
59    /// Builds the plugin from node config.
60    ///
61    /// Accepted keys (all optional; only the configured statuses are
62    /// intercepted): `error_404`, `error_500`, `error_502`, `error_503` —
63    /// each an object with:
64    /// - `body` (string, default an APISIX-style HTML page for that status):
65    ///   the replacement response body.
66    /// - `content_type` (string, default `text/html`): the replacement
67    ///   `content-type` header value.
68    ///
69    /// An `error_XXX` key set to a non-object, or `body`/`content_type` with
70    /// a non-string value, fails at config load. Setting a key to an empty
71    /// object `{}` selects the defaults for that status.
72    ///
73    /// ```yaml
74    /// type: error-page
75    /// config:
76    ///   error_502:
77    ///     body: '{"error": "upstream unavailable"}'
78    ///     content_type: application/json
79    ///   error_503: {}   # default featherbit HTML page
80    /// ```
81    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
82        let mut pages = HashMap::new();
83
84        for (status, title) in SUPPORTED_STATUS {
85            let key = format!("error_{}", status);
86            let Some(raw) = config.get(&key) else {
87                continue;
88            };
89            let obj = raw
90                .as_object()
91                .ok_or_else(|| format!("{} must be an object", key))?;
92
93            let body = match obj.get("body") {
94                None => default_body(title),
95                Some(v) => v
96                    .as_str()
97                    .ok_or_else(|| format!("{}.body must be a string", key))?
98                    .to_string(),
99            };
100            let content_type = match obj.get("content_type") {
101                None => "text/html".to_string(),
102                Some(v) => v
103                    .as_str()
104                    .ok_or_else(|| format!("{}.content_type must be a string", key))?
105                    .to_string(),
106            };
107
108            pages.insert(
109                status,
110                ErrorPage {
111                    body: Bytes::from(body),
112                    content_type,
113                },
114            );
115        }
116
117        Ok(Self { pages })
118    }
119}
120
121#[async_trait]
122impl Plugin for ErrorPagePlugin {
123    fn plugin_type(&self) -> &str {
124        "error-page"
125    }
126
127    async fn execute(
128        &self,
129        mut ctx: Context,
130        _named_inputs: &HashMap<String, serde_json::Value>,
131    ) -> PluginResult {
132        // Only gateway-generated responses are intercepted (APISIX skips
133        // responses sourced from the upstream).
134        let gateway_generated = !ctx.errors.is_empty();
135
136        if gateway_generated {
137            if let Some(page) = self.pages.get(&ctx.response.status_code) {
138                ctx.response.body = page.body.clone();
139                ctx.response
140                    .headers
141                    .insert("content-type".to_string(), vec![page.content_type.clone()]);
142                // Body-mutation convention: the server layer recomputes the
143                // length; the configured page is not encoded.
144                ctx.response.headers.remove("content-length");
145                ctx.response.headers.remove("content-encoding");
146            }
147        }
148
149        Ok(PluginOutput {
150            context: ctx,
151            named_outputs: HashMap::new(),
152        })
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
160
161    fn test_context(status: u16, gateway_generated: bool) -> Context {
162        let mut response_headers = HashMap::new();
163        response_headers.insert("content-type".to_string(), vec!["text/plain".to_string()]);
164        response_headers.insert("content-length".to_string(), vec!["8".to_string()]);
165        let errors = if gateway_generated {
166            vec![GatewayError {
167                node_id: "upstream-1".to_string(),
168                code: "UPSTREAM_CONNECTION_ERROR".to_string(),
169                message: "connect refused".to_string(),
170                metadata: HashMap::new(),
171            }]
172        } else {
173            Vec::new()
174        };
175        Context {
176            request: GatewayRequest {
177                method: "GET".to_string(),
178                path: "/test".to_string(),
179                host: "localhost".to_string(),
180                scheme: "http".to_string(),
181                headers: HashMap::new(),
182                query_params: HashMap::new(),
183                body: Bytes::new(),
184                remote_addr: "127.0.0.1:12345".to_string(),
185                protocol: Protocol::Http1,
186            },
187            response: GatewayResponse {
188                status_code: status,
189                headers: response_headers,
190                body: Bytes::from_static(b"original"),
191            },
192            message: HashMap::new(),
193            errors,
194        }
195    }
196
197    fn plugin(config: serde_json::Value) -> ErrorPagePlugin {
198        let map: HashMap<String, serde_json::Value> =
199            serde_json::from_value(config).expect("test config must be an object");
200        ErrorPagePlugin::from_config(&map).expect("config should be valid")
201    }
202
203    #[tokio::test]
204    async fn test_error_page_replaces_gateway_error() {
205        let p = plugin(serde_json::json!({
206            "error_502": {
207                "body": "{\"error\": \"bad gateway\"}",
208                "content_type": "application/json"
209            }
210        }));
211        let out = p
212            .execute(test_context(502, true), &HashMap::new())
213            .await
214            .unwrap();
215        assert_eq!(
216            out.context.response.body.as_ref(),
217            b"{\"error\": \"bad gateway\"}"
218        );
219        assert_eq!(
220            out.context.response.headers.get("content-type"),
221            Some(&vec!["application/json".to_string()])
222        );
223        assert!(!out.context.response.headers.contains_key("content-length"));
224        assert_eq!(out.context.response.status_code, 502);
225    }
226
227    #[tokio::test]
228    async fn test_error_page_default_body_and_content_type() {
229        let p = plugin(serde_json::json!({ "error_503": {} }));
230        let out = p
231            .execute(test_context(503, true), &HashMap::new())
232            .await
233            .unwrap();
234        let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
235        assert!(body.contains("<h1>503 Service Unavailable</h1>"), "{body}");
236        assert!(body.contains("featherbit"));
237        assert_eq!(
238            out.context.response.headers.get("content-type"),
239            Some(&vec!["text/html".to_string()])
240        );
241    }
242
243    #[tokio::test]
244    async fn test_error_page_skips_upstream_sourced_response() {
245        // Same 502, but no gateway errors recorded → the response came from
246        // the upstream and must pass through untouched.
247        let p = plugin(serde_json::json!({ "error_502": {} }));
248        let out = p
249            .execute(test_context(502, false), &HashMap::new())
250            .await
251            .unwrap();
252        assert_eq!(out.context.response.body.as_ref(), b"original");
253        assert_eq!(
254            out.context.response.headers.get("content-type"),
255            Some(&vec!["text/plain".to_string()])
256        );
257        assert!(out.context.response.headers.contains_key("content-length"));
258    }
259
260    #[tokio::test]
261    async fn test_error_page_skips_unconfigured_status() {
262        let p = plugin(serde_json::json!({ "error_502": {} }));
263        // 500 is a supported status but has no configured page here.
264        let out = p
265            .execute(test_context(500, true), &HashMap::new())
266            .await
267            .unwrap();
268        assert_eq!(out.context.response.body.as_ref(), b"original");
269
270        // Non-error statuses always pass through.
271        let p = plugin(serde_json::json!({ "error_502": {} }));
272        let out = p
273            .execute(test_context(200, true), &HashMap::new())
274            .await
275            .unwrap();
276        assert_eq!(out.context.response.body.as_ref(), b"original");
277    }
278
279    #[test]
280    fn test_error_page_config_validation() {
281        for bad in [
282            serde_json::json!({ "error_502": "not an object" }),
283            serde_json::json!({ "error_404": { "body": 42 } }),
284            serde_json::json!({ "error_500": { "content_type": [] } }),
285        ] {
286            let map: HashMap<String, serde_json::Value> = serde_json::from_value(bad).unwrap();
287            assert!(ErrorPagePlugin::from_config(&map).is_err());
288        }
289        // Unsupported error_XXX keys are ignored (only 404/500/502/503 exist
290        // in APISIX's schema).
291        let map: HashMap<String, serde_json::Value> =
292            serde_json::from_value(serde_json::json!({ "error_418": {} })).unwrap();
293        let p = ErrorPagePlugin::from_config(&map).unwrap();
294        assert!(p.pages.is_empty());
295    }
296}