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, GatewayError};
42use crate::plugins::resources::PluginResources;
43use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
44
45#[derive(Debug, Clone, Copy, PartialEq)]
47enum Role {
48 Check,
50 Observe,
52}
53
54pub struct ApiBreakerPlugin {
59 role: Role,
60 id: String,
62 unhealthy_statuses: Vec<u16>,
64 unhealthy_failures: u32,
66 healthy_statuses: Vec<u16>,
68 healthy_successes: u32,
70 break_base_sec: u64,
72 max_breaker_sec: u64,
74 break_response_code: u16,
76 break_response_body: Option<String>,
78 resources: Arc<PluginResources>,
79}
80
81fn 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 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 assert!(check.execute(ctx(0), &HashMap::new()).await.is_ok());
402
403 observe.execute(ctx(500), &HashMap::new()).await.unwrap();
405 observe.execute(ctx(500), &HashMap::new()).await.unwrap();
406
407 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(); observe.execute(ctx(500), &HashMap::new()).await.unwrap();
452 assert!(check.execute(ctx(0), &HashMap::new()).await.is_ok());
454 }
455}