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