Skip to main content

featherbit/plugins/native/
api_breaker.rs

1//! Circuit breaker (`api-breaker`), ported from APISIX's
2//! `apisix/plugins/api-breaker.lua`.
3//!
4//! A circuit breaker must both *decide* whether to admit a request (before the
5//! upstream call) and *observe* the outcome (after it) — two moments a single
6//! featherbit node cannot span. The behavior is expressed as a **pair of
7//! nodes** sharing one breaker, linked by a required `id`:
8//!
9//! - a **check** node placed *before* `upstream`, which trips to the
10//!   short-circuit response while the breaker is open, and
11//! - an **observe** node placed *after* `upstream`, which feeds the response
12//!   status back into the breaker so it opens and closes.
13//!
14//! Both nodes carry the same `id`, so [`crate::traffic::BreakerRegistry`] hands
15//! them the same [`crate::traffic::BreakerState`].
16//!
17//! # Wiring
18//!
19//! ```text
20//!            ┌──────────────────┐        ┌──────────┐        ┌──────────────────┐
21//!  listener →│ api-breaker      │success →│ upstream │─ any ─→│ api-breaker      │→ client
22//!            │  (phase=check)   │        │          │        │  (phase=observe) │
23//!            └──────────────────┘        └──────────┘        └──────────────────┘
24//!                    │ broken                                       (records the
25//!                    ▼                                          upstream status into
26//!               client.in                                        the shared breaker)
27//!          (break_response_code)
28//! ```
29//!
30//! The check node's `broken` port goes to `client.in`: while the breaker is
31//! open the request short-circuits to the client with the configured break
32//! response. The observe node passes the response through untouched and
33//! simply records the status; wire it on the path(s) out of `upstream` that
34//! carry the real upstream response.
35
36use async_trait::async_trait;
37use bytes::Bytes;
38use std::collections::HashMap;
39use std::sync::Arc;
40
41use crate::context::Context;
42use crate::plugins::resources::PluginResources;
43use crate::plugins::{Plugin, PluginOutput, PluginResult};
44use crate::vars::template::Template;
45
46/// Which half of the pair this node is.
47#[derive(Debug, Clone, Copy, PartialEq)]
48enum Role {
49    /// Runs before `upstream`: rejects while the breaker is open.
50    Check,
51    /// Runs after `upstream`: records the response status.
52    Observe,
53}
54
55/// One node of an `api-breaker` check/observe pair.
56///
57/// Holds a handle to the process-wide [`crate::traffic::BreakerRegistry`]; the
58/// shared breaker is resolved by the configured `id`.
59pub struct ApiBreakerPlugin {
60    role: Role,
61    /// Shared breaker identity — links the check and observe nodes.
62    id: String,
63    /// Statuses that count as unhealthy (open the breaker).
64    unhealthy_statuses: Vec<u16>,
65    /// Consecutive unhealthy responses before the breaker opens.
66    unhealthy_failures: u32,
67    /// Statuses that count as healthy (close the breaker).
68    healthy_statuses: Vec<u16>,
69    /// Consecutive healthy responses before the breaker fully closes.
70    healthy_successes: u32,
71    /// Base cooldown seconds; doubles each successive trip (APISIX `2^n`).
72    break_base_sec: u64,
73    /// Upper bound on the cooldown.
74    max_breaker_sec: u64,
75    /// Status returned while the breaker is open.
76    break_response_code: u16,
77    /// Optional body returned while the breaker is open. Supports
78    /// `{{namespace.path}}` references (no legacy `$var` interpolation —
79    /// this field never supported it, so this sweep must not start).
80    break_response_body: Option<Template>,
81    resources: Arc<PluginResources>,
82}
83
84/// Reads a `Vec<u16>` of HTTP statuses from a JSON array, if present and valid.
85fn parse_statuses(v: Option<&serde_json::Value>) -> Result<Option<Vec<u16>>, String> {
86    let Some(v) = v else { return Ok(None) };
87    let arr = v
88        .as_array()
89        .ok_or("http_statuses must be an array of integers")?;
90    let mut out = Vec::with_capacity(arr.len());
91    for item in arr {
92        let n = item
93            .as_u64()
94            .ok_or("http_statuses entries must be integers")?;
95        if !(200..=599).contains(&n) {
96            return Err(format!(
97                "http_statuses entry {} is out of range (200-599)",
98                n
99            ));
100        }
101        out.push(n as u16);
102    }
103    if out.is_empty() {
104        return Err("http_statuses must not be empty".to_string());
105    }
106    Ok(Some(out))
107}
108
109impl ApiBreakerPlugin {
110    /// Builds one node of the pair from node config.
111    ///
112    /// Accepted keys:
113    /// - `phase` / `role` (string, **required**): `check` (before upstream) or
114    ///   `observe` (after upstream).
115    /// - `id` (string, **required**): shared breaker identity; the check and
116    ///   observe nodes of one pair must use the same `id`.
117    /// - `unhealthy.http_statuses` (array, default `[500]`): statuses counted
118    ///   as failures. `unhealthy.failures` (integer, default `3`): consecutive
119    ///   failures before the breaker opens.
120    /// - `healthy.http_statuses` (array, default `[200]`): statuses counted as
121    ///   successes. `healthy.successes` (integer, default `3`): consecutive
122    ///   successes before the breaker fully closes.
123    /// - `break_response_code` (integer, default `502`): status returned while
124    ///   the breaker is open.
125    /// - `break_response_body` (string, optional): body returned while open;
126    ///   supports `{{namespace.path}}` references.
127    /// - `break_base_sec` (integer, default `2`): base cooldown; the open
128    ///   window grows as `break_base_sec * 2^trip` (APISIX's `2^n` backoff).
129    /// - `max_breaker_sec` (integer, default `300`, min `3`): cooldown ceiling.
130    ///
131    /// ```yaml
132    /// # before upstream
133    /// type: api-breaker
134    /// config:
135    ///   phase: check
136    ///   id: orders-api
137    ///   break_response_code: 502
138    ///   unhealthy: { http_statuses: [500, 503], failures: 3 }
139    ///   healthy: { http_statuses: [200], successes: 3 }
140    /// ---
141    /// # after upstream
142    /// type: api-breaker
143    /// config:
144    ///   phase: observe
145    ///   id: orders-api
146    ///   unhealthy: { http_statuses: [500, 503], failures: 3 }
147    ///   healthy: { http_statuses: [200], successes: 3 }
148    /// ```
149    pub fn from_config(
150        config: &HashMap<String, serde_json::Value>,
151        resources: &Arc<PluginResources>,
152    ) -> Result<Self, String> {
153        let role = match config
154            .get("phase")
155            .or_else(|| config.get("role"))
156            .and_then(|v| v.as_str())
157        {
158            Some("check") => Role::Check,
159            Some("observe") => Role::Observe,
160            Some(other) => {
161                return Err(format!(
162                    "api-breaker: unknown phase/role '{}' (expected 'check' or 'observe')",
163                    other
164                ))
165            }
166            None => {
167                return Err(
168                    "api-breaker: 'phase' (or 'role') is required: 'check' or 'observe'"
169                        .to_string(),
170                )
171            }
172        };
173
174        let id = config
175            .get("id")
176            .and_then(|v| v.as_str())
177            .filter(|s| !s.trim().is_empty())
178            .ok_or("api-breaker: 'id' is required (links the check/observe pair)")?
179            .to_string();
180
181        let unhealthy = config.get("unhealthy");
182        let healthy = config.get("healthy");
183
184        let unhealthy_statuses = parse_statuses(unhealthy.and_then(|u| u.get("http_statuses")))?
185            .unwrap_or_else(|| vec![500]);
186        let healthy_statuses = parse_statuses(healthy.and_then(|h| h.get("http_statuses")))?
187            .unwrap_or_else(|| vec![200]);
188
189        let unhealthy_failures = unhealthy
190            .and_then(|u| u.get("failures"))
191            .and_then(|v| v.as_u64())
192            .unwrap_or(3);
193        if unhealthy_failures < 1 {
194            return Err("api-breaker: unhealthy.failures must be >= 1".to_string());
195        }
196
197        let healthy_successes = healthy
198            .and_then(|h| h.get("successes"))
199            .and_then(|v| v.as_u64())
200            .unwrap_or(3);
201        if healthy_successes < 1 {
202            return Err("api-breaker: healthy.successes must be >= 1".to_string());
203        }
204
205        let break_response_code = config
206            .get("break_response_code")
207            .and_then(|v| v.as_u64())
208            .map(|c| c as u16)
209            .unwrap_or(502);
210        if !(200..=599).contains(&break_response_code) {
211            return Err(format!(
212                "api-breaker: break_response_code {} is out of range (200-599)",
213                break_response_code
214            ));
215        }
216
217        let break_response_body = config
218            .get("break_response_body")
219            .and_then(|v| v.as_str())
220            // Discard warnings here — the compile-time walk (a later task)
221            // reports well-formed-but-unknown references; execution must not.
222            .map(|s| Template::parse(s).0);
223
224        let break_base_sec = config
225            .get("break_base_sec")
226            .and_then(|v| v.as_u64())
227            .unwrap_or(2)
228            .max(1);
229
230        let max_breaker_sec = config
231            .get("max_breaker_sec")
232            .and_then(|v| v.as_u64())
233            .unwrap_or(300);
234        if max_breaker_sec < 3 {
235            return Err("api-breaker: max_breaker_sec must be >= 3".to_string());
236        }
237
238        Ok(Self {
239            role,
240            id,
241            unhealthy_statuses,
242            unhealthy_failures: unhealthy_failures as u32,
243            healthy_statuses,
244            healthy_successes: healthy_successes as u32,
245            break_base_sec,
246            max_breaker_sec,
247            break_response_code,
248            break_response_body,
249            resources: resources.clone(),
250        })
251    }
252}
253
254#[async_trait]
255impl Plugin for ApiBreakerPlugin {
256    fn plugin_type(&self) -> &str {
257        "api-breaker"
258    }
259
260    async fn execute(&self, mut ctx: Context) -> PluginResult {
261        let breaker = self.resources.traffic.breakers.breaker(&self.id);
262
263        match self.role {
264            Role::Check => {
265                let allowed = breaker.lock().await.allow();
266                if allowed {
267                    Ok(PluginOutput::success(ctx))
268                } else {
269                    ctx.response.status_code = self.break_response_code;
270                    if let Some(body) = &self.break_response_body {
271                        ctx.response.body = Bytes::from(body.render(&ctx).into_owned());
272                    }
273                    Ok(PluginOutput::on_port(ctx, "broken"))
274                }
275            }
276            Role::Observe => {
277                let status = ctx.response.status_code;
278                if self.unhealthy_statuses.contains(&status) {
279                    breaker.lock().await.record_unhealthy(
280                        self.unhealthy_failures,
281                        self.break_base_sec,
282                        self.max_breaker_sec,
283                    );
284                } else if self.healthy_statuses.contains(&status) {
285                    breaker.lock().await.record_healthy(self.healthy_successes);
286                }
287                Ok(PluginOutput::success(ctx))
288            }
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
297
298    fn ctx(status: u16) -> Context {
299        Context {
300            request: GatewayRequest {
301                method: "GET".to_string(),
302                path: "/".to_string(),
303                host: "localhost".to_string(),
304                scheme: "http".to_string(),
305                headers: HashMap::new(),
306                query_params: HashMap::new(),
307                body: Bytes::new(),
308                remote_addr: "10.0.0.1:5000".to_string(),
309                protocol: Protocol::Http1,
310            },
311            response: GatewayResponse {
312                status_code: status,
313                headers: HashMap::new(),
314                body: Bytes::new(),
315                stream: None,
316            },
317            message: HashMap::new(),
318            errors: Vec::new(),
319        }
320    }
321
322    fn cfg(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
323        pairs
324            .iter()
325            .map(|(k, v)| (k.to_string(), v.clone()))
326            .collect()
327    }
328
329    #[test]
330    fn test_missing_id_and_bad_role_fail() {
331        let r = PluginResources::empty();
332        assert!(
333            ApiBreakerPlugin::from_config(&cfg(&[("phase", serde_json::json!("check"))]), &r)
334                .is_err()
335        );
336        assert!(ApiBreakerPlugin::from_config(
337            &cfg(&[
338                ("phase", serde_json::json!("bogus")),
339                ("id", serde_json::json!("x"))
340            ]),
341            &r
342        )
343        .is_err());
344        assert!(
345            ApiBreakerPlugin::from_config(&cfg(&[("id", serde_json::json!("x"))]), &r).is_err()
346        );
347    }
348
349    #[tokio::test]
350    async fn test_observe_trips_and_check_rejects() {
351        let r = PluginResources::empty();
352        let check = ApiBreakerPlugin::from_config(
353            &cfg(&[
354                ("phase", serde_json::json!("check")),
355                ("id", serde_json::json!("svc")),
356                ("break_response_code", serde_json::json!(502)),
357                (
358                    "unhealthy",
359                    serde_json::json!({"http_statuses": [500], "failures": 2}),
360                ),
361                (
362                    "healthy",
363                    serde_json::json!({"http_statuses": [200], "successes": 2}),
364                ),
365                ("max_breaker_sec", serde_json::json!(60)),
366            ]),
367            &r,
368        )
369        .unwrap();
370        let observe = ApiBreakerPlugin::from_config(
371            &cfg(&[
372                ("phase", serde_json::json!("observe")),
373                ("id", serde_json::json!("svc")),
374                (
375                    "unhealthy",
376                    serde_json::json!({"http_statuses": [500], "failures": 2}),
377                ),
378                (
379                    "healthy",
380                    serde_json::json!({"http_statuses": [200], "successes": 2}),
381                ),
382                ("max_breaker_sec", serde_json::json!(60)),
383            ]),
384            &r,
385        )
386        .unwrap();
387
388        // Breaker starts closed → check allows.
389        assert!(check.execute(ctx(0)).await.unwrap().port.is_none());
390
391        // Two unhealthy responses (threshold 2) → breaker opens.
392        observe.execute(ctx(500)).await.unwrap();
393        observe.execute(ctx(500)).await.unwrap();
394
395        // Now check rejects with the break response on `broken`.
396        let out = check
397            .execute(ctx(0))
398            .await
399            .expect("check completes with a `broken` outcome while the breaker is open");
400        assert_eq!(out.port, Some("broken"));
401        assert_eq!(out.context.response.status_code, 502);
402    }
403
404    #[tokio::test]
405    async fn test_break_response_body_renders_template() {
406        let r = PluginResources::empty();
407        let check = ApiBreakerPlugin::from_config(
408            &cfg(&[
409                ("phase", serde_json::json!("check")),
410                ("id", serde_json::json!("svc3")),
411                (
412                    "break_response_body",
413                    serde_json::json!("blocked path={{request.path}}"),
414                ),
415                (
416                    "unhealthy",
417                    serde_json::json!({"http_statuses": [500], "failures": 1}),
418                ),
419            ]),
420            &r,
421        )
422        .unwrap();
423        let observe = ApiBreakerPlugin::from_config(
424            &cfg(&[
425                ("phase", serde_json::json!("observe")),
426                ("id", serde_json::json!("svc3")),
427                (
428                    "unhealthy",
429                    serde_json::json!({"http_statuses": [500], "failures": 1}),
430                ),
431            ]),
432            &r,
433        )
434        .unwrap();
435
436        observe.execute(ctx(500)).await.unwrap();
437        let out = check
438            .execute(ctx(0))
439            .await
440            .expect("check completes with a `broken` outcome while the breaker is open");
441        assert_eq!(out.port, Some("broken"));
442        assert_eq!(out.context.response.body.as_ref(), b"blocked path=/");
443    }
444
445    #[tokio::test]
446    async fn test_healthy_status_resets_streak() {
447        let r = PluginResources::empty();
448        let check = ApiBreakerPlugin::from_config(
449            &cfg(&[
450                ("phase", serde_json::json!("check")),
451                ("id", serde_json::json!("svc2")),
452                (
453                    "unhealthy",
454                    serde_json::json!({"http_statuses": [500], "failures": 3}),
455                ),
456            ]),
457            &r,
458        )
459        .unwrap();
460        let observe = ApiBreakerPlugin::from_config(
461            &cfg(&[
462                ("phase", serde_json::json!("observe")),
463                ("id", serde_json::json!("svc2")),
464                (
465                    "unhealthy",
466                    serde_json::json!({"http_statuses": [500], "failures": 3}),
467                ),
468                (
469                    "healthy",
470                    serde_json::json!({"http_statuses": [200], "successes": 1}),
471                ),
472            ]),
473            &r,
474        )
475        .unwrap();
476
477        observe.execute(ctx(500)).await.unwrap();
478        observe.execute(ctx(500)).await.unwrap();
479        observe.execute(ctx(200)).await.unwrap(); // resets
480        observe.execute(ctx(500)).await.unwrap();
481        // Only one unhealthy since the reset (< threshold 3) → still closed.
482        assert!(check.execute(ctx(0)).await.unwrap().port.is_none());
483    }
484}