1use async_trait::async_trait;
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::Duration;
9
10use bytes::Bytes;
11
12use crate::balancer::{Balancer, Strategy, Target};
13use crate::context::stream::ResponseStream;
14use crate::context::{Context, GatewayError, Protocol};
15use crate::outbound::idle::{body_holding, idle_timeout_body};
16use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
17use crate::plugins::resources::PluginResources;
18use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
19
20pub struct UpstreamPlugin {
27 balancer: Arc<Balancer>,
32 client: Arc<OutboundClient>,
34 timeout: Duration,
36 stream_idle_timeout: Duration,
39 tls: bool,
41 ssl_verify: bool,
44 tls_identity: Option<Arc<crate::outbound::tls::UpstreamTls>>,
47}
48
49impl std::fmt::Debug for UpstreamPlugin {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("UpstreamPlugin")
55 .field("timeout", &self.timeout)
56 .field("tls", &self.tls)
57 .field("ssl_verify", &self.ssl_verify)
58 .field("tls_identity_set", &self.tls_identity.is_some())
59 .finish()
60 }
61}
62
63impl UpstreamPlugin {
64 pub fn from_config(
121 config: &HashMap<String, serde_json::Value>,
122 resources: &Arc<PluginResources>,
123 ) -> Result<Self, String> {
124 let targets = config
127 .get("targets")
128 .and_then(|v| v.as_array())
129 .map(|seq| {
130 seq.iter()
131 .filter_map(|t| {
132 let mapping = t.as_object()?;
133 let host = mapping.get("host")?.as_str()?.to_string();
134 let port = mapping.get("port")?.as_u64()? as u16;
135 Some(Target { host, port })
136 })
137 .collect::<Vec<_>>()
138 })
139 .unwrap_or_default();
140
141 let strategy = match config
144 .get("load_balancing")
145 .or_else(|| config.get("load_balancer"))
146 {
147 None => Strategy::default(),
148 Some(v) => {
149 let s = v
150 .as_str()
151 .ok_or_else(|| "load_balancing must be a string".to_string())?;
152 Strategy::parse(s)?
153 }
154 };
155
156 let balancer = Arc::new(Balancer::new(targets, strategy)?);
157
158 let timeout = Duration::from_millis(
159 config
160 .get("timeout_ms")
161 .and_then(|v| v.as_u64())
162 .unwrap_or(60_000),
163 );
164 let stream_idle_timeout = Duration::from_millis(
165 config
166 .get("stream_idle_timeout_ms")
167 .and_then(|v| v.as_u64())
168 .unwrap_or(60_000),
169 );
170
171 let tls = config.get("tls").and_then(|v| v.as_bool()).unwrap_or(false);
172 let ssl_verify = config
173 .get("ssl_verify")
174 .and_then(|v| v.as_bool())
175 .unwrap_or(true);
176
177 let string_config_value = |key: &str| -> Result<Option<String>, String> {
178 match config.get(key) {
179 None => Ok(None),
180 Some(v) => match v.as_str() {
181 Some(s) => Ok(Some(s.to_string())),
182 None => Err(format!("{} must be a string", key)),
183 },
184 }
185 };
186
187 let client_cert_path = string_config_value("client_cert_path")?;
188 let client_key_path = string_config_value("client_key_path")?;
189 let ca_cert_path = string_config_value("ca_cert_path")?;
190
191 let any_mtls_key =
192 client_cert_path.is_some() || client_key_path.is_some() || ca_cert_path.is_some();
193 if any_mtls_key && !tls {
194 return Err(
195 "client_cert_path/client_key_path/ca_cert_path require tls: true".to_string(),
196 );
197 }
198 if client_cert_path.is_some() != client_key_path.is_some() {
199 return Err("client_cert_path and client_key_path must be set together".to_string());
200 }
201 if ca_cert_path.is_some() && !ssl_verify {
204 return Err("ca_cert_path with ssl_verify: false is contradictory".to_string());
205 }
206
207 let tls_identity = if any_mtls_key {
208 let client = client_cert_path.as_deref().zip(client_key_path.as_deref());
209 let identity = crate::outbound::tls::UpstreamTls::load(
210 client,
211 ca_cert_path.as_deref(),
212 ssl_verify,
213 )?;
214 crate::outbound::tls::UpstreamTls::register(&identity);
215 Some(identity)
216 } else {
217 None
218 };
219
220 Ok(Self {
221 balancer,
222 client: resources.outbound.clone(),
223 timeout,
224 stream_idle_timeout,
225 tls,
226 ssl_verify,
227 tls_identity,
228 })
229 }
230
231 fn outbound_request(
237 &self,
238 ctx: &Context,
239 uri: String,
240 method: http::Method,
241 target: &Target,
242 ) -> OutboundRequest {
243 OutboundRequest {
244 method,
245 url: uri,
246 headers: forwarded_headers(ctx, target),
247 body: ctx.request.body.clone(),
248 timeout: self.timeout,
249 ssl_verify: self.ssl_verify,
250 tls: self.tls_identity.clone(),
251 }
252 }
253}
254
255fn forwarded_headers(ctx: &Context, target: &Target) -> Vec<(String, String)> {
259 let mut headers: Vec<(String, String)> = Vec::new();
260 for (key, values) in &ctx.request.headers {
261 if key.eq_ignore_ascii_case("host") {
262 continue;
263 }
264 for value in values {
265 headers.push((key.clone(), value.clone()));
266 }
267 }
268 headers.push((
269 "host".to_string(),
270 format!("{}:{}", target.host, target.port),
271 ));
272 headers
273}
274
275fn request_target(ctx: &Context) -> String {
284 let query = crate::vars::query_string(ctx);
285 if query.is_empty() {
286 ctx.request.path.clone()
287 } else {
288 format!("{}?{}", ctx.request.path, query)
289 }
290}
291
292fn map_outbound_error(e: OutboundError, target: &Target) -> (&'static str, String) {
297 match &e {
298 OutboundError::Timeout(d) => (
299 "UPSTREAM_TIMEOUT",
300 format!(
301 "Upstream {}:{} timed out after {:?}",
302 target.host, target.port, d
303 ),
304 ),
305 OutboundError::InvalidRequest(m) => (
306 "UPSTREAM_REQUEST_BUILD_ERROR",
307 format!("Failed to build upstream request: {}", m),
308 ),
309 OutboundError::Transport(m) => (
310 "UPSTREAM_CONNECTION_ERROR",
311 format!(
312 "Failed to reach upstream {}:{}: {}",
313 target.host, target.port, m
314 ),
315 ),
316 }
317}
318
319#[async_trait]
320impl Plugin for UpstreamPlugin {
321 fn plugin_type(&self) -> &str {
322 "upstream"
323 }
324
325 async fn execute(&self, mut ctx: Context) -> PluginResult {
326 let target_idx = self.balancer.select(&ctx.request.remote_addr);
327 let target = self.balancer.target(target_idx);
328
329 if ctx.request.protocol == Protocol::WebSocket {
336 let ws_target = request_target(&ctx);
338 ctx.message.insert(
339 "__ws_upstream_host".to_string(),
340 serde_json::json!(target.host),
341 );
342 ctx.message.insert(
343 "__ws_upstream_port".to_string(),
344 serde_json::json!(target.port),
345 );
346 ctx.message.insert(
347 "__ws_upstream_path".to_string(),
348 serde_json::json!(ws_target),
349 );
350 ctx.message
351 .insert("__ws_upstream_tls".to_string(), serde_json::json!(self.tls));
352 ctx.message.insert(
353 "__ws_upstream_verify".to_string(),
354 serde_json::json!(self.ssl_verify),
355 );
356 if let Some(identity) = &self.tls_identity {
357 ctx.message.insert(
358 "__ws_upstream_tls_key".to_string(),
359 serde_json::json!(identity.cache_key()),
360 );
361 }
362 ctx.response.status_code = 101;
363 return Ok(PluginOutput::success(ctx));
364 }
365
366 let scheme = if self.tls { "https" } else { "http" };
367 let uri = format!(
368 "{}://{}:{}{}",
369 scheme,
370 target.host,
371 target.port,
372 request_target(&ctx)
373 );
374
375 let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::GET);
376
377 let may_stream = ctx
378 .message
379 .get("__may_stream")
380 .and_then(|v| v.as_bool())
381 .unwrap_or(false);
382
383 if may_stream {
384 let guard = self.balancer.owned_acquire(target_idx);
388 let outbound = self.outbound_request(&ctx, uri, method, target);
389 return match self.client.request_streaming(outbound).await {
390 Ok(resp) => {
391 ctx.response.status_code = resp.status;
392 ctx.response.headers = resp.headers;
393 ctx.response.body = Bytes::new();
396 let idled = idle_timeout_body(resp.body, self.stream_idle_timeout);
403 let held = body_holding(idled, vec![Box::new(guard)]);
404 ctx.response.stream = Some(ResponseStream::new(held));
405 Ok(PluginOutput::success(ctx))
406 }
407 Err(e) => {
408 let (code, message) = map_outbound_error(e, target);
413 Err(PluginExecutionError {
414 context: ctx,
415 error: GatewayError {
416 node_id: String::new(),
417 code: code.to_string(),
418 message,
419 metadata: HashMap::new(),
420 },
421 })
422 }
423 };
424 }
425
426 let _in_flight_guard = self.balancer.acquire(target_idx);
427 let outbound = self.outbound_request(&ctx, uri, method, target);
428
429 let response = match self.client.request(outbound).await {
430 Ok(resp) => resp,
431 Err(e) => {
432 let (code, message) = map_outbound_error(e, target);
433 let error = GatewayError {
434 node_id: String::new(),
435 code: code.to_string(),
436 message,
437 metadata: HashMap::new(),
438 };
439 return Err(PluginExecutionError {
440 context: ctx,
441 error,
442 });
443 }
444 };
445
446 ctx.response.status_code = response.status;
448 ctx.response.headers = response.headers;
449 ctx.response.body = response.body;
450 ctx.response.stream = None;
455
456 Ok(PluginOutput::success(ctx))
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 fn plugin_with(strategy: Option<&str>, key: &str, n_targets: usize) -> UpstreamPlugin {
465 let targets: Vec<serde_json::Value> = (0..n_targets)
466 .map(|i| serde_json::json!({ "host": format!("backend-{}", i), "port": 3000 }))
467 .collect();
468 let mut config = HashMap::new();
469 config.insert("targets".to_string(), serde_json::Value::Array(targets));
470 if let Some(s) = strategy {
471 config.insert(key.to_string(), serde_json::Value::String(s.to_string()));
472 }
473 UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap()
474 }
475
476 #[test]
477 fn test_load_balancing_parsing_and_aliases() {
478 assert_eq!(
480 plugin_with(Some("least_connections"), "load_balancing", 2)
481 .balancer
482 .strategy(),
483 Strategy::LeastConnections
484 );
485 assert_eq!(
487 plugin_with(Some("round-robin"), "load_balancer", 2)
488 .balancer
489 .strategy(),
490 Strategy::RoundRobin
491 );
492 assert_eq!(
493 plugin_with(Some("least-conn"), "load_balancer", 2)
494 .balancer
495 .strategy(),
496 Strategy::LeastConnections
497 );
498 assert_eq!(
499 plugin_with(Some("ip_hash"), "load_balancing", 2)
500 .balancer
501 .strategy(),
502 Strategy::IpHash
503 );
504 assert_eq!(
506 plugin_with(None, "load_balancing", 2).balancer.strategy(),
507 Strategy::RoundRobin
508 );
509 }
510
511 #[test]
512 fn test_load_balancing_rejects_unknown() {
513 let mut config = HashMap::new();
514 config.insert(
515 "targets".to_string(),
516 serde_json::json!([{ "host": "backend", "port": 3000 }]),
517 );
518 config.insert(
519 "load_balancing".to_string(),
520 serde_json::Value::String("random".to_string()),
521 );
522 assert!(UpstreamPlugin::from_config(&config, &PluginResources::empty()).is_err());
523 }
524
525 #[tokio::test]
526 async fn test_websocket_branch_stashes_target_and_101() {
527 use crate::context::GatewayRequest;
528
529 let plugin = plugin_with(None, "load_balancing", 1);
530 let mut req_headers = HashMap::new();
531 req_headers.insert("upgrade".to_string(), vec!["websocket".to_string()]);
532 let ctx = Context::new(GatewayRequest {
533 method: "GET".into(),
534 path: "/ws/chat".into(),
535 host: "h".into(),
536 scheme: "http".into(),
537 headers: req_headers,
538 query_params: HashMap::new(),
539 body: bytes::Bytes::new(),
540 remote_addr: "1.2.3.4:5".into(),
541 protocol: Protocol::WebSocket,
542 });
543
544 let out = plugin.execute(ctx).await.unwrap();
547 assert_eq!(out.context.response.status_code, 101);
548 assert_eq!(
549 out.context.message.get("__ws_upstream_host").unwrap(),
550 "backend-0"
551 );
552 assert_eq!(out.context.message.get("__ws_upstream_port").unwrap(), 3000);
553 assert_eq!(
554 out.context.message.get("__ws_upstream_path").unwrap(),
555 "/ws/chat"
556 );
557 assert_eq!(plugin.balancer.in_flight_count(0), 0);
559 assert_eq!(out.context.message.get("__ws_upstream_tls").unwrap(), false);
561 assert_eq!(
562 out.context.message.get("__ws_upstream_verify").unwrap(),
563 true
564 );
565 }
566
567 #[test]
568 fn test_tls_config_parses_and_defaults() {
569 let default = plugin_with(None, "load_balancing", 1);
571 assert!(!default.tls);
572 assert!(default.ssl_verify);
573
574 let mut config = HashMap::new();
576 config.insert(
577 "targets".to_string(),
578 serde_json::json!([{ "host": "backend", "port": 443 }]),
579 );
580 config.insert("tls".to_string(), serde_json::json!(true));
581 config.insert("ssl_verify".to_string(), serde_json::json!(false));
582 let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
583 assert!(plugin.tls);
584 assert!(!plugin.ssl_verify);
585 }
586
587 fn write_identity(tag: &str) -> (String, String, String) {
588 let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
591 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
592 let ca_key = rcgen::KeyPair::generate().unwrap();
593 let ca_cert = ca_params.self_signed(&ca_key).unwrap();
594 let ca_issuer = rcgen::Issuer::from_ca_cert_der(ca_cert.der(), &ca_key).unwrap();
595 let leaf_params = rcgen::CertificateParams::new(vec!["client".to_string()]).unwrap();
596 let leaf_key = rcgen::KeyPair::generate().unwrap();
597 let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_issuer).unwrap();
598 let dir = std::env::temp_dir();
599 let pid = std::process::id();
600 let cert = dir.join(format!("featherbit_up_{}_{}.crt", tag, pid));
601 let key = dir.join(format!("featherbit_up_{}_{}.key", tag, pid));
602 let ca = dir.join(format!("featherbit_up_{}_{}.ca.crt", tag, pid));
603 std::fs::write(&cert, leaf_cert.pem()).unwrap();
604 std::fs::write(&key, leaf_key.serialize_pem()).unwrap();
605 std::fs::write(&ca, ca_cert.pem()).unwrap();
606 (
607 cert.to_str().unwrap().to_string(),
608 key.to_str().unwrap().to_string(),
609 ca.to_str().unwrap().to_string(),
610 )
611 }
612
613 fn mtls_config() -> HashMap<String, serde_json::Value> {
615 let mut config = HashMap::new();
616 config.insert(
617 "targets".to_string(),
618 serde_json::json!([{"host": "backend", "port": 443}]),
619 );
620 config
621 }
622
623 #[test]
624 fn test_mtls_config_requires_tls_true() {
625 let (cert, key, _) = write_identity("needstls");
626 let mut config = mtls_config();
627 config.insert("client_cert_path".to_string(), serde_json::json!(cert));
628 config.insert("client_key_path".to_string(), serde_json::json!(key));
629 let err = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
631 assert!(err.contains("tls"), "err was: {}", err);
632 }
633
634 #[test]
635 fn test_mtls_config_cert_and_key_must_pair() {
636 let (cert, _, _) = write_identity("pair");
637 let mut config = mtls_config();
638 config.insert("tls".to_string(), serde_json::json!(true));
639 config.insert("client_cert_path".to_string(), serde_json::json!(cert));
640 let err = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
641 assert!(err.contains("together"), "err was: {}", err);
642 }
643
644 #[test]
645 fn test_mtls_config_ca_with_no_verify_rejected() {
646 let (_, _, ca) = write_identity("contradiction");
647 let mut config = mtls_config();
648 config.insert("tls".to_string(), serde_json::json!(true));
649 config.insert("ssl_verify".to_string(), serde_json::json!(false));
650 config.insert("ca_cert_path".to_string(), serde_json::json!(ca));
651 assert!(UpstreamPlugin::from_config(&config, &PluginResources::empty()).is_err());
652 }
653
654 #[test]
655 fn test_mtls_config_loads_identity_and_registers() {
656 let (cert, key, ca) = write_identity("loads");
657 let mut config = mtls_config();
658 config.insert("tls".to_string(), serde_json::json!(true));
659 config.insert("client_cert_path".to_string(), serde_json::json!(cert));
660 config.insert("client_key_path".to_string(), serde_json::json!(key));
661 config.insert("ca_cert_path".to_string(), serde_json::json!(ca));
662 let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
663 let id = plugin.tls_identity.as_ref().expect("identity loaded");
664 assert!(crate::outbound::tls::UpstreamTls::lookup(id.cache_key()).is_some());
666 }
667
668 #[test]
669 fn test_mtls_config_absent_means_no_identity() {
670 let mut config = mtls_config();
671 config.insert("tls".to_string(), serde_json::json!(true));
672 let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
673 assert!(plugin.tls_identity.is_none());
674 }
675
676 #[test]
677 fn test_mtls_config_non_string_keys_rejected() {
678 for key in ["client_cert_path", "client_key_path", "ca_cert_path"] {
679 for bad_value in [
680 serde_json::json!(123),
681 serde_json::json!(true),
682 serde_json::json!(["x"]),
683 ] {
684 let mut config = mtls_config();
685 config.insert("tls".to_string(), serde_json::json!(true));
686 config.insert(key.to_string(), bad_value.clone());
687 let err =
688 UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
689 assert!(
690 err.contains(&format!("{} must be a string", key)),
691 "key {} value {:?} produced err: {}",
692 key,
693 bad_value,
694 err
695 );
696 }
697 }
698 }
699
700 async fn spawn_request_line_capture() -> (u16, tokio::sync::oneshot::Receiver<String>) {
707 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
708 let port = listener.local_addr().unwrap().port();
709 let (tx, rx) = tokio::sync::oneshot::channel();
710 tokio::spawn(async move {
711 if let Ok((mut stream, _)) = listener.accept().await {
712 use tokio::io::{AsyncReadExt, AsyncWriteExt};
713 let mut buf = [0u8; 4096];
714 let n = stream.read(&mut buf).await.unwrap_or(0);
715 let text = String::from_utf8_lossy(&buf[..n]).to_string();
716 let line = text.lines().next().unwrap_or("").to_string();
717 let _ = tx.send(line);
718 let _ = stream
719 .write_all(
720 b"HTTP/1.1 200 OK
721content-length: 2
722
723ok",
724 )
725 .await;
726 let _ = stream.shutdown().await;
727 }
728 });
729 (port, rx)
730 }
731
732 fn ctx_with_query(path: &str, query: Vec<(&str, Vec<&str>)>) -> Context {
733 use crate::context::GatewayRequest;
734 let query_params: HashMap<String, Vec<String>> = query
735 .into_iter()
736 .map(|(k, vs)| {
737 (
738 k.to_string(),
739 vs.into_iter().map(|v| v.to_string()).collect(),
740 )
741 })
742 .collect();
743 Context::new(GatewayRequest {
744 method: "GET".into(),
745 path: path.into(),
746 host: "h".into(),
747 scheme: "http".into(),
748 headers: HashMap::new(),
749 query_params,
750 body: bytes::Bytes::new(),
751 remote_addr: "1.2.3.4:5".into(),
752 protocol: Protocol::Http1,
753 })
754 }
755
756 fn plugin_at(port: u16) -> UpstreamPlugin {
757 let mut config = HashMap::new();
758 config.insert(
759 "targets".to_string(),
760 serde_json::json!([{ "host": "127.0.0.1", "port": port }]),
761 );
762 UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap()
763 }
764
765 #[tokio::test]
770 async fn test_query_string_is_forwarded_to_upstream() {
771 let (port, rx) = spawn_request_line_capture().await;
772 let ctx = ctx_with_query(
773 "/realms/example/protocol/openid-connect/auth",
774 vec![("client_id", vec!["apisix"])],
775 );
776
777 plugin_at(port).execute(ctx).await.unwrap();
778
779 let request_line = rx.await.unwrap();
780 assert!(
781 request_line.contains("client_id=apisix"),
782 "query string dropped from outbound request-target: {request_line}"
783 );
784 }
785
786 #[tokio::test]
791 async fn test_websocket_upstream_path_keeps_query_string() {
792 use crate::context::GatewayRequest;
793
794 let mut req_headers = HashMap::new();
795 req_headers.insert("upgrade".to_string(), vec!["websocket".to_string()]);
796 let mut query_params = HashMap::new();
797 query_params.insert("token".to_string(), vec!["abc123".to_string()]);
798 let ctx = Context::new(GatewayRequest {
799 method: "GET".into(),
800 path: "/ws/chat".into(),
801 host: "h".into(),
802 scheme: "http".into(),
803 headers: req_headers,
804 query_params,
805 body: bytes::Bytes::new(),
806 remote_addr: "1.2.3.4:5".into(),
807 protocol: Protocol::WebSocket,
808 });
809
810 let out = plugin_with(None, "load_balancing", 1)
811 .execute(ctx)
812 .await
813 .unwrap();
814
815 assert_eq!(
816 out.context.message.get("__ws_upstream_path").unwrap(),
817 "/ws/chat?token=abc123"
818 );
819 }
820
821 #[tokio::test]
824 async fn test_upstream_streams_when_permitted() {
825 let (port, _rx) = spawn_request_line_capture().await;
826 let mut ctx = ctx_with_query("/stream", vec![]);
827 ctx.message
828 .insert("__may_stream".to_string(), serde_json::json!(true));
829
830 let out = plugin_at(port).execute(ctx).await.unwrap();
831
832 assert!(out.context.response.stream.is_some(), "expected a stream");
833 assert!(
834 out.context.response.body.is_empty(),
835 "invariant: body must be empty when stream is set"
836 );
837 }
838
839 #[tokio::test]
841 async fn test_upstream_buffers_when_not_permitted() {
842 let (port, _rx) = spawn_request_line_capture().await;
843 let ctx = ctx_with_query("/stream", vec![]);
844
845 let out = plugin_at(port).execute(ctx).await.unwrap();
846
847 assert!(out.context.response.stream.is_none());
848 assert_eq!(
849 out.context.response.body.as_ref(),
850 b"ok",
851 "buffered path must actually carry the upstream's body"
852 );
853 }
854
855 async fn spawn_pausable_stream_server() -> (u16, tokio::sync::oneshot::Sender<()>) {
861 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
862 let port = listener.local_addr().unwrap().port();
863 let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
864 tokio::spawn(async move {
865 if let Ok((mut stream, _)) = listener.accept().await {
866 use tokio::io::{AsyncReadExt, AsyncWriteExt};
867 let mut buf = [0u8; 4096];
868 let _ = stream.read(&mut buf).await;
869 let _ = stream
870 .write_all(
871 b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n5\r\nhello\r\n",
872 )
873 .await;
874 let _ = resume_rx.await;
875 let _ = stream.write_all(b"6\r\nworld!\r\n0\r\n\r\n").await;
876 let _ = stream.shutdown().await;
877 }
878 });
879 (port, resume_tx)
880 }
881
882 #[tokio::test]
891 async fn test_in_flight_guard_released_only_when_stream_completes() {
892 use http_body_util::BodyExt;
893
894 let (port, resume_tx) = spawn_pausable_stream_server().await;
895 let plugin = plugin_at(port);
896 let mut ctx = ctx_with_query("/stream", vec![]);
897 ctx.message
898 .insert("__may_stream".to_string(), serde_json::json!(true));
899
900 let out = plugin.execute(ctx).await.unwrap();
901 let stream = out
902 .context
903 .response
904 .stream
905 .expect("expected a stream when __may_stream is set");
906
907 assert_eq!(
908 plugin.balancer.in_flight_count(0),
909 1,
910 "in-flight count must stay held while the stream is still open"
911 );
912
913 let (body, _guards) = stream.into_parts();
914 let _ = resume_tx.send(());
915 let collected = body.collect().await.unwrap().to_bytes();
916 assert_eq!(collected.as_ref(), b"helloworld!");
917
918 assert_eq!(
919 plugin.balancer.in_flight_count(0),
920 0,
921 "in-flight count must release once the stream body completes"
922 );
923 }
924}