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