featherbit/plugins/native/
api_breaker.rs1use 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#[derive(Debug, Clone, Copy, PartialEq)]
48enum Role {
49 Check,
51 Observe,
53}
54
55pub struct ApiBreakerPlugin {
60 role: Role,
61 id: String,
63 unhealthy_statuses: Vec<u16>,
65 unhealthy_failures: u32,
67 healthy_statuses: Vec<u16>,
69 healthy_successes: u32,
71 break_base_sec: u64,
73 max_breaker_sec: u64,
75 break_response_code: u16,
77 break_response_body: Option<Template>,
81 resources: Arc<PluginResources>,
82}
83
84fn 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 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 .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 assert!(check.execute(ctx(0)).await.unwrap().port.is_none());
390
391 observe.execute(ctx(500)).await.unwrap();
393 observe.execute(ctx(500)).await.unwrap();
394
395 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(); observe.execute(ctx(500)).await.unwrap();
481 assert!(check.execute(ctx(0)).await.unwrap().port.is_none());
483 }
484}