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