Skip to main content

featherbit/plugins/native/
traffic_split.rs

1//! Traffic-split plugin (`traffic-split`) — a port of Apache APISIX's
2//! `traffic-split` plugin for weighted / conditional traffic steering
3//! (canary, blue-green, A/B).
4//!
5//! A request is matched against an ordered list of `rules`; the **first** rule
6//! whose `match` condition passes selects a set of `weighted_upstreams`. One
7//! weighted slot is then picked by weighted round-robin. A slot either names a
8//! concrete target set (`upstream.targets`) or carries only a `weight` (the
9//! "default" slot, meaning "use the route's normal upstream").
10//!
11//! **Split-node wiring** (featherbit design — read this before wiring the
12//! graph). This node sits **before** the route's normal `upstream` node. It
13//! exposes the usual `success` / `error` ports and the caller wires them so
14//! that the two outcomes reach the right place:
15//!
16//! - **Default slot picked (or no rule matched):** the plugin returns `Ok(ctx)`
17//!   unchanged. Wire `success` → the rest of the pipeline (the normal
18//!   `upstream` node). The request is proxied by the route as usual.
19//! - **Target slot picked:** the plugin proxies the request itself to the
20//!   chosen target (reusing the shared outbound client), writes the backend's
21//!   reply onto `Context.response`, and **short-circuits** by returning
22//!   `Err(code TRAFFIC_SPLIT_ROUTED)` with the response already populated. Wire
23//!   `error` → `client.in` (the same convention `fault-injection` and
24//!   `mocking` use for "stop here, send this response"). If the split target
25//!   itself is unreachable the node fails with `TRAFFIC_SPLIT_UPSTREAM_ERROR`
26//!   and a prepared `502` body — also routed through `error`.
27//!
28//! This makes canary/blue-green trivial: give the default slot weight 90 and a
29//! canary target set weight 10, and 10% of matching traffic is proxied to the
30//! canary while the other 90% falls through to the normal upstream.
31
32use async_trait::async_trait;
33use bytes::Bytes;
34use std::collections::HashMap;
35use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
36use std::sync::Arc;
37use std::time::Duration;
38
39use crate::context::{Context, GatewayError};
40use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
41use crate::plugins::resources::PluginResources;
42use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
43use crate::vars::Expr;
44
45/// Steers matching requests to a weighted set of upstream targets, or lets them
46/// fall through to the route's normal upstream.
47pub struct TrafficSplitPlugin {
48    rules: Vec<Rule>,
49    /// Whole-call deadline per request the plugin proxies itself.
50    timeout: Duration,
51    /// Shared pooled HTTP client (from `PluginResources`).
52    client: Arc<OutboundClient>,
53}
54
55struct Rule {
56    /// One APISIX triple-array expression (rules AND-ed); `None` matches all.
57    matcher: Option<Expr>,
58    slots: Vec<Slot>,
59    /// Sum of slot weights (always > 0 — enforced at load).
60    total_weight: u64,
61    /// Round-robin cursor for weighted selection between `slots`.
62    cursor: AtomicU64,
63}
64
65struct Slot {
66    weight: u64,
67    /// `Some` → proxy to one of these targets; `None` → the "default" slot,
68    /// meaning fall through to the route's normal upstream.
69    targets: Option<Vec<Target>>,
70    /// Round-robin cursor within the target set (unused for the default slot).
71    target_cursor: AtomicUsize,
72}
73
74/// A single backend address (`host:port`) a split slot forwards to.
75#[derive(Debug, Clone)]
76struct Target {
77    host: String,
78    port: u16,
79}
80
81/// Parses a `{targets: [{host, port}], ...}` upstream object into a target
82/// list. Entries missing `host`/`port` are skipped; an empty result is an
83/// error (an `upstream` block must name at least one reachable target).
84fn parse_targets(v: &serde_json::Value, field: &str) -> Result<Vec<Target>, String> {
85    let obj = v
86        .as_object()
87        .ok_or_else(|| format!("{field} must be an object"))?;
88    let targets = obj
89        .get("targets")
90        .or_else(|| obj.get("nodes"))
91        .and_then(|v| v.as_array())
92        .map(|seq| {
93            seq.iter()
94                .filter_map(|t| {
95                    let m = t.as_object()?;
96                    let host = m.get("host")?.as_str()?.to_string();
97                    let port = m.get("port")?.as_u64()? as u16;
98                    Some(Target { host, port })
99                })
100                .collect::<Vec<_>>()
101        })
102        .unwrap_or_default();
103    if targets.is_empty() {
104        return Err(format!(
105            "{field}.targets must contain at least one {{host, port}} entry"
106        ));
107    }
108    Ok(targets)
109}
110
111impl TrafficSplitPlugin {
112    /// Builds the plugin from node config. All `match` expressions are compiled
113    /// here at config load (fail-fast), weights must be non-negative integers,
114    /// and every rule needs at least one positively-weighted slot.
115    ///
116    /// Accepted keys:
117    /// - `rules` (array, **required**, non-empty): evaluated in order; the
118    ///   first rule whose `match` passes is used.
119    ///   - `match` (array, optional): an APISIX triple-array condition (rules
120    ///     AND-ed, see [`crate::vars`]). Omit to match every request.
121    ///   - `weighted_upstreams` (array, **required**, non-empty): the weighted
122    ///     slots one of which is chosen by weighted round-robin. Each slot:
123    ///     - `upstream` (object, optional): `{targets: [{host, port}], ...}`.
124    ///       When present, this slot proxies matching requests to one of the
125    ///       targets (round-robin within the set). When **absent**, the slot is
126    ///       the "default" — matching requests fall through to the route's
127    ///       normal upstream.
128    ///     - `weight` (integer >= 0, default `1`): selection weight.
129    /// - `timeout_ms` (integer, default `60000`): whole-call deadline for
130    ///   requests the plugin proxies itself.
131    ///
132    /// ```yaml
133    /// type: traffic-split
134    /// config:
135    ///   timeout_ms: 60000
136    ///   rules:
137    ///     - match:
138    ///         - ["arg_canary", "==", "1"]
139    ///       weighted_upstreams:
140    ///         # 90% fall through to the route's normal upstream
141    ///         - weight: 90
142    ///         # 10% proxied to the canary target set
143    ///         - upstream:
144    ///             targets:
145    ///               - host: canary-backend
146    ///                 port: 8080
147    ///           weight: 10
148    /// ```
149    pub fn from_config(
150        config: &HashMap<String, serde_json::Value>,
151        resources: &Arc<PluginResources>,
152    ) -> Result<Self, String> {
153        let raw_rules = config
154            .get("rules")
155            .and_then(|v| v.as_array())
156            .filter(|r| !r.is_empty())
157            .ok_or("traffic-split requires a non-empty 'rules' array")?;
158
159        let mut rules = Vec::with_capacity(raw_rules.len());
160        for (idx, raw) in raw_rules.iter().enumerate() {
161            let obj = raw
162                .as_object()
163                .ok_or_else(|| format!("rules[{idx}] must be an object"))?;
164
165            let matcher = match obj.get("match") {
166                None | Some(serde_json::Value::Null) => None,
167                Some(v) => Some(Expr::parse(v).map_err(|e| format!("rules[{idx}].match: {e}"))?),
168            };
169
170            let raw_slots = obj
171                .get("weighted_upstreams")
172                .and_then(|v| v.as_array())
173                .filter(|s| !s.is_empty())
174                .ok_or_else(|| {
175                    format!("rules[{idx}] requires a non-empty 'weighted_upstreams' array")
176                })?;
177
178            let mut slots = Vec::with_capacity(raw_slots.len());
179            for (sidx, raw_slot) in raw_slots.iter().enumerate() {
180                let sobj = raw_slot.as_object().ok_or_else(|| {
181                    format!("rules[{idx}].weighted_upstreams[{sidx}] must be an object")
182                })?;
183
184                let weight = match sobj.get("weight") {
185                    None => 1,
186                    Some(v) => v.as_u64().ok_or_else(|| {
187                        format!(
188                            "rules[{idx}].weighted_upstreams[{sidx}].weight must be a non-negative integer"
189                        )
190                    })?,
191                };
192
193                let targets = match sobj.get("upstream") {
194                    None | Some(serde_json::Value::Null) => None,
195                    Some(v) => Some(parse_targets(
196                        v,
197                        &format!("rules[{idx}].weighted_upstreams[{sidx}].upstream"),
198                    )?),
199                };
200
201                slots.push(Slot {
202                    weight,
203                    targets,
204                    target_cursor: AtomicUsize::new(0),
205                });
206            }
207
208            let total_weight: u64 = slots.iter().map(|s| s.weight).sum();
209            if total_weight == 0 {
210                return Err(format!(
211                    "rules[{idx}] needs at least one weighted_upstream with weight > 0"
212                ));
213            }
214
215            rules.push(Rule {
216                matcher,
217                slots,
218                total_weight,
219                cursor: AtomicU64::new(0),
220            });
221        }
222
223        let timeout = Duration::from_millis(
224            config
225                .get("timeout_ms")
226                .and_then(|v| v.as_u64())
227                .unwrap_or(60_000),
228        );
229
230        Ok(Self {
231            rules,
232            timeout,
233            client: resources.outbound.clone(),
234        })
235    }
236
237    /// Returns the first rule whose `match` passes (or that has no `match`),
238    /// or `None` when no rule matches (→ passthrough to the normal upstream).
239    fn select_rule(&self, ctx: &Context) -> Option<&Rule> {
240        self.rules
241            .iter()
242            .find(|rule| rule.matcher.as_ref().is_none_or(|e| e.eval(ctx)))
243    }
244
245    /// Proxies the request to `target`, writing the backend reply onto
246    /// `ctx.response`. Mirrors the `upstream` node: overrides the `Host` header
247    /// with the target and forwards method/headers/body unchanged. On success
248    /// returns `Ok(())`; on transport/timeout failure returns the gateway
249    /// error code + message so the caller can prepare a 502.
250    async fn proxy_to_target(
251        &self,
252        ctx: &mut Context,
253        target: &Target,
254    ) -> Result<(), (&'static str, String)> {
255        let url = format!("http://{}:{}{}", target.host, target.port, ctx.request.path);
256        let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::GET);
257
258        let mut headers: Vec<(String, String)> = Vec::new();
259        for (key, values) in &ctx.request.headers {
260            if key.eq_ignore_ascii_case("host") {
261                continue;
262            }
263            for value in values {
264                headers.push((key.clone(), value.clone()));
265            }
266        }
267        headers.push((
268            "host".to_string(),
269            format!("{}:{}", target.host, target.port),
270        ));
271
272        let outbound = OutboundRequest {
273            method,
274            url,
275            headers,
276            body: ctx.request.body.clone(),
277            timeout: self.timeout,
278            ssl_verify: true,
279            tls: None,
280        };
281
282        match self.client.request(outbound).await {
283            Ok(resp) => {
284                ctx.response.status_code = resp.status;
285                ctx.response.headers = resp.headers;
286                ctx.response.body = resp.body;
287                Ok(())
288            }
289            Err(e) => {
290                let (code, message) = match &e {
291                    OutboundError::Timeout(d) => (
292                        "TRAFFIC_SPLIT_UPSTREAM_ERROR",
293                        format!(
294                            "traffic-split target {}:{} timed out after {:?}",
295                            target.host, target.port, d
296                        ),
297                    ),
298                    OutboundError::InvalidRequest(m) => (
299                        "TRAFFIC_SPLIT_UPSTREAM_ERROR",
300                        format!("traffic-split failed to build request: {m}"),
301                    ),
302                    OutboundError::Transport(m) => (
303                        "TRAFFIC_SPLIT_UPSTREAM_ERROR",
304                        format!(
305                            "traffic-split failed to reach target {}:{}: {m}",
306                            target.host, target.port
307                        ),
308                    ),
309                };
310                Err((code, message))
311            }
312        }
313    }
314}
315
316impl Rule {
317    /// Picks a slot by weighted round-robin: a shared cursor modulo the rule's
318    /// total weight walks the cumulative weight buckets, so a slot with weight
319    /// 3 is chosen 3 out of every `total_weight` calls. Zero-weight slots are
320    /// never selected.
321    fn pick_slot(&self) -> &Slot {
322        let mark = self.cursor.fetch_add(1, Ordering::Relaxed) % self.total_weight;
323        let mut acc = 0;
324        for slot in &self.slots {
325            acc += slot.weight;
326            if mark < acc {
327                return slot;
328            }
329        }
330        // Unreachable: mark < total_weight == sum(weights) and total_weight > 0.
331        self.slots.last().expect("slots is non-empty")
332    }
333}
334
335impl Slot {
336    /// Round-robins across the slot's target set. Only valid for target slots
337    /// (`targets` is `Some`).
338    fn pick_target<'a>(&self, targets: &'a [Target]) -> &'a Target {
339        let idx = self.target_cursor.fetch_add(1, Ordering::Relaxed) % targets.len();
340        &targets[idx]
341    }
342}
343
344#[async_trait]
345impl Plugin for TrafficSplitPlugin {
346    fn plugin_type(&self) -> &str {
347        "traffic-split"
348    }
349
350    async fn execute(
351        &self,
352        mut ctx: Context,
353        _named_inputs: &HashMap<String, serde_json::Value>,
354    ) -> PluginResult {
355        // No rule matched → fall through to the route's normal upstream.
356        let rule = match self.select_rule(&ctx) {
357            Some(rule) => rule,
358            None => {
359                return Ok(PluginOutput {
360                    context: ctx,
361                    named_outputs: HashMap::new(),
362                })
363            }
364        };
365
366        let slot = rule.pick_slot();
367        let targets = match &slot.targets {
368            // Default slot → fall through to the normal upstream (success port).
369            None => {
370                return Ok(PluginOutput {
371                    context: ctx,
372                    named_outputs: HashMap::new(),
373                })
374            }
375            Some(targets) => targets,
376        };
377
378        // Target slot → proxy the request ourselves and short-circuit.
379        let target = slot.pick_target(targets).clone();
380        match self.proxy_to_target(&mut ctx, &target).await {
381            Ok(()) => Err(PluginExecutionError {
382                context: ctx,
383                error: GatewayError {
384                    node_id: String::new(),
385                    code: "TRAFFIC_SPLIT_ROUTED".to_string(),
386                    message: format!(
387                        "traffic-split proxied request to {}:{}",
388                        target.host, target.port
389                    ),
390                    metadata: HashMap::new(),
391                },
392            }),
393            Err((code, message)) => {
394                // Prepare a 502 so the error edge (→ client.in) has a body.
395                ctx.response.status_code = 502;
396                ctx.response.body = Bytes::from(
397                    r#"{"error": "bad_gateway", "message": "traffic-split target unreachable"}"#,
398                );
399                ctx.response.headers.insert(
400                    "content-type".to_string(),
401                    vec!["application/json".to_string()],
402                );
403                Err(PluginExecutionError {
404                    context: ctx,
405                    error: GatewayError {
406                        node_id: String::new(),
407                        code: code.to_string(),
408                        message,
409                        metadata: HashMap::new(),
410                    },
411                })
412            }
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
421
422    fn test_ctx(canary: Option<&str>) -> Context {
423        let mut query = HashMap::new();
424        if let Some(c) = canary {
425            query.insert("canary".to_string(), vec![c.to_string()]);
426        }
427        Context {
428            request: GatewayRequest {
429                method: "GET".to_string(),
430                path: "/api".to_string(),
431                host: "example.com".to_string(),
432                scheme: "http".to_string(),
433                headers: HashMap::new(),
434                query_params: query,
435                body: Bytes::new(),
436                remote_addr: "10.1.2.3:44321".to_string(),
437                protocol: Protocol::Http1,
438            },
439            response: GatewayResponse {
440                status_code: 0,
441                headers: HashMap::new(),
442                body: Bytes::new(),
443            },
444            message: HashMap::new(),
445            errors: Vec::new(),
446        }
447    }
448
449    fn plugin(config: serde_json::Value) -> Result<TrafficSplitPlugin, String> {
450        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
451        TrafficSplitPlugin::from_config(&map, &PluginResources::empty())
452    }
453
454    #[test]
455    fn test_select_rule_matches_first_passing() {
456        let p = plugin(serde_json::json!({
457            "rules": [
458                {
459                    "match": [["arg_canary", "==", "1"]],
460                    "weighted_upstreams": [{ "weight": 1 }]
461                },
462                {
463                    "weighted_upstreams": [{ "weight": 1 }]
464                }
465            ]
466        }))
467        .unwrap();
468
469        // canary=1 → first rule selected.
470        let ctx = test_ctx(Some("1"));
471        assert!(std::ptr::eq(p.select_rule(&ctx).unwrap(), &p.rules[0]));
472        // canary absent → first rule's match fails, fall to the catch-all rule.
473        let ctx = test_ctx(None);
474        assert!(std::ptr::eq(p.select_rule(&ctx).unwrap(), &p.rules[1]));
475    }
476
477    #[test]
478    fn test_no_rule_matches_returns_none() {
479        let p = plugin(serde_json::json!({
480            "rules": [{
481                "match": [["arg_canary", "==", "1"]],
482                "weighted_upstreams": [{ "weight": 1 }]
483            }]
484        }))
485        .unwrap();
486        assert!(p.select_rule(&test_ctx(Some("0"))).is_none());
487    }
488
489    #[test]
490    fn test_weighted_round_robin_distribution() {
491        // 3:1 split between a default slot and a target slot over a fixed
492        // cursor: 8 draws → 6 default, 2 target, deterministically.
493        let p = plugin(serde_json::json!({
494            "rules": [{
495                "weighted_upstreams": [
496                    { "weight": 3 },
497                    { "upstream": { "targets": [{ "host": "canary", "port": 80 }] }, "weight": 1 }
498                ]
499            }]
500        }))
501        .unwrap();
502        let rule = &p.rules[0];
503
504        let mut default_hits = 0;
505        let mut target_hits = 0;
506        for _ in 0..8 {
507            match &rule.pick_slot().targets {
508                None => default_hits += 1,
509                Some(_) => target_hits += 1,
510            }
511        }
512        assert_eq!(default_hits, 6);
513        assert_eq!(target_hits, 2);
514    }
515
516    #[test]
517    fn test_pick_target_round_robins_within_set() {
518        let p = plugin(serde_json::json!({
519            "rules": [{
520                "weighted_upstreams": [{
521                    "upstream": { "targets": [
522                        { "host": "a", "port": 80 },
523                        { "host": "b", "port": 80 }
524                    ] },
525                    "weight": 1
526                }]
527            }]
528        }))
529        .unwrap();
530        let slot = &p.rules[0].slots[0];
531        let targets = slot.targets.as_ref().unwrap();
532        let picks: Vec<&str> = (0..4)
533            .map(|_| slot.pick_target(targets).host.as_str())
534            .collect();
535        assert_eq!(picks, vec!["a", "b", "a", "b"]);
536    }
537
538    #[tokio::test]
539    async fn test_default_slot_returns_ok_passthrough() {
540        // A rule with only a default (no-upstream) slot passes the request
541        // through untouched — no network call.
542        let p = plugin(serde_json::json!({
543            "rules": [{ "weighted_upstreams": [{ "weight": 1 }] }]
544        }))
545        .unwrap();
546        let out = p.execute(test_ctx(None), &HashMap::new()).await.unwrap();
547        assert_eq!(out.context.response.status_code, 0);
548    }
549
550    #[tokio::test]
551    async fn test_no_match_returns_ok_passthrough() {
552        let p = plugin(serde_json::json!({
553            "rules": [{
554                "match": [["arg_canary", "==", "yes"]],
555                "weighted_upstreams": [{
556                    "upstream": { "targets": [{ "host": "canary", "port": 80 }] },
557                    "weight": 1
558                }]
559            }]
560        }))
561        .unwrap();
562        // canary != yes → no rule matches → Ok passthrough, no proxy attempt.
563        let out = p
564            .execute(test_ctx(Some("no")), &HashMap::new())
565            .await
566            .unwrap();
567        assert_eq!(out.context.response.status_code, 0);
568    }
569
570    #[test]
571    fn test_target_slot_prepares_proxy_path() {
572        // The selected target slot resolves to a concrete target (the input to
573        // the proxy round-trip) without making any network call.
574        let p = plugin(serde_json::json!({
575            "rules": [{
576                "weighted_upstreams": [{
577                    "upstream": { "targets": [{ "host": "canary-backend", "port": 8080 }] },
578                    "weight": 1
579                }]
580            }]
581        }))
582        .unwrap();
583        let slot = p.rules[0].pick_slot();
584        let targets = slot.targets.as_ref().expect("target slot");
585        let target = slot.pick_target(targets);
586        assert_eq!(target.host, "canary-backend");
587        assert_eq!(target.port, 8080);
588    }
589
590    #[test]
591    fn test_config_errors() {
592        // No rules.
593        assert!(plugin(serde_json::json!({})).is_err());
594        assert!(plugin(serde_json::json!({ "rules": [] })).is_err());
595        // Empty weighted_upstreams.
596        assert!(plugin(serde_json::json!({
597            "rules": [{ "weighted_upstreams": [] }]
598        }))
599        .is_err());
600        // All-zero weights.
601        assert!(plugin(serde_json::json!({
602            "rules": [{ "weighted_upstreams": [{ "weight": 0 }] }]
603        }))
604        .is_err());
605        // Negative weight.
606        assert!(plugin(serde_json::json!({
607            "rules": [{ "weighted_upstreams": [{ "weight": -1 }] }]
608        }))
609        .is_err());
610        // upstream present but no targets.
611        assert!(plugin(serde_json::json!({
612            "rules": [{ "weighted_upstreams": [{ "upstream": {}, "weight": 1 }] }]
613        }))
614        .is_err());
615        // Invalid match expression surfaces at load.
616        assert!(plugin(serde_json::json!({
617            "rules": [{
618                "match": [["uri", "bogus", "/x"]],
619                "weighted_upstreams": [{ "weight": 1 }]
620            }]
621        }))
622        .is_err());
623    }
624}