1use std::{
2 error::Error,
3 future::Future,
4 io::IoSlice,
5 net::SocketAddr,
6 pin::Pin,
7 sync::Arc,
8 task::{self, Context, Poll},
9 time::Duration,
10};
11
12use bytes::{Buf, Bytes};
13use http::{
14 HeaderMap, Uri,
15 header::{CONTENT_LENGTH, HOST},
16 uri::Scheme,
17};
18use http_body::{Body, Frame, SizeHint};
19use http_body_util::{BodyExt, combinators::UnsyncBoxBody};
20use hyper_util::{
21 client::legacy::{
22 Client,
23 connect::{Connected, Connection},
24 },
25 rt::{TokioExecutor, TokioIo},
26};
27use opentelemetry_semantic_conventions::attribute as otel_attribute;
28use spin_factor_outbound_networking::{
29 ComponentTlsClientConfigs, TlsClientConfig,
30 config::{allowed_hosts::OutboundAllowedHosts, blocked_networks::BlockedNetworks},
31};
32use spin_factors::RuntimeFactorsInstanceState;
33use tokio::{
34 io::{AsyncRead, AsyncWrite, ReadBuf},
35 net::TcpStream,
36 time::timeout,
37};
38use tokio_rustls::client::TlsStream;
39use tower_service::Service;
40use tracing::{Instrument, Span, field::Empty, instrument};
41use wasmtime::component::HasData;
42use wasmtime_wasi_http::{
43 Error as WasiHttpError, RequestOptions, WasiHttp, WasiHttpCtxView, WasiHttpHooks,
44 p2::{bindings::http::types::ErrorCode, body::HyperOutgoingBody, types::IncomingResponse},
45 p3,
46};
47
48use spin_factor_outbound_networking::{ConnectionPermit, ConnectionSemaphore};
49
50use crate::{
51 InstanceHttpHooks, OutboundHttpFactor, SelfRequestOrigin,
52 intercept::{InterceptOutcome, OutboundHttpInterceptor},
53 wasi_2023_10_18, wasi_2023_11_10, wasi_2026_03_15,
54};
55
56use tracing_opentelemetry::OpenTelemetrySpanExt as _;
57
58const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600);
59
60pub(crate) struct HasHttp;
61
62impl HasData for HasHttp {
63 type Data<'a> = WasiHttpCtxView<'a>;
64}
65
66struct OutgoingRequestConfig {
67 use_tls: bool,
68 connect_timeout: Duration,
69 first_byte_timeout: Duration,
70}
71
72impl WasiHttpHooks for InstanceHttpHooks {
73 #[instrument(
74 name = "spin_outbound_http.send_request",
75 skip_all,
76 fields(
77 otel.kind = "client",
78 {otel_attribute::URL_FULL} = Empty,
79 {otel_attribute::HTTP_REQUEST_METHOD} = %request.method(),
80 otel.name = %request.method(),
81 {otel_attribute::HTTP_RESPONSE_BODY_SIZE} = Empty,
82 {otel_attribute::HTTP_RESPONSE_STATUS_CODE} = Empty,
83 {otel_attribute::SERVER_ADDRESS} = Empty,
84 {otel_attribute::SERVER_PORT} = Empty,
85 )
86 )]
87 #[allow(clippy::type_complexity)]
88 fn send_request(
89 &mut self,
90 request: http::Request<UnsyncBoxBody<Bytes, WasiHttpError>>,
91 options: Option<RequestOptions>,
92 fut: Box<dyn Future<Output = Result<(), WasiHttpError>> + Send>,
93 ) -> Box<
94 dyn Future<
95 Output = Result<
96 (
97 http::Response<UnsyncBoxBody<Bytes, WasiHttpError>>,
98 Box<dyn Future<Output = Result<(), WasiHttpError>> + Send>,
99 ),
100 WasiHttpError,
101 >,
102 > + Send,
103 > {
104 self.otel.reparent_tracing_span();
105
106 _ = fut;
115
116 let request_sender = RequestSender {
117 allowed_hosts: self.allowed_hosts.clone(),
118 component_tls_configs: self.component_tls_configs.clone(),
119 request_interceptor: self.request_interceptor.clone(),
120 self_request_origin: self.self_request_origin.clone(),
121 blocked_networks: self.blocked_networks.clone(),
122 http_clients: self.wasi_http_clients.clone(),
123 semaphore: self.semaphore.clone(),
124 };
125 let between_bytes_timeout = options
126 .and_then(|v| v.between_bytes_timeout)
127 .unwrap_or(DEFAULT_TIMEOUT);
128 let config = OutgoingRequestConfig {
129 use_tls: request.uri().scheme() == Some(&Scheme::HTTPS),
130 connect_timeout: options
131 .and_then(|v| v.connect_timeout)
132 .unwrap_or(DEFAULT_TIMEOUT),
133 first_byte_timeout: options
134 .and_then(|v| v.first_byte_timeout)
135 .unwrap_or(DEFAULT_TIMEOUT),
136 };
137 Box::new(
138 async move {
139 match request_sender
140 .send(
141 request.map(|body| body.map_err(WasiHttpError::from).boxed_unsync()),
142 config,
143 )
144 .await
145 {
146 Ok(IncomingResponse { resp, .. }) => Ok((
147 resp.map(|body| {
148 BetweenBytesTimeoutBody {
149 body: Some(body),
150 sleep: None,
151 timeout: between_bytes_timeout,
152 byte_count: 0,
153 span: Some(Span::current()),
154 }
155 .boxed_unsync()
156 }),
157 Box::new(async {
158 Ok(())
162 }) as Box<dyn Future<Output = _> + Send>,
163 )),
164 Err(http_error) => Err(http_error),
165 }
166 }
167 .in_current_span(),
168 )
169 }
170}
171
172pin_project_lite::pin_project! {
173 struct BetweenBytesTimeoutBody<B> {
174 body: Option<B>,
176 #[pin]
177 sleep: Option<tokio::time::Sleep>,
178 timeout: Duration,
179 byte_count: u64,
180 span: Option<Span>,
181 }
182}
183
184impl<B: Body<Error = WasiHttpError> + Unpin> Body for BetweenBytesTimeoutBody<B> {
185 type Data = B::Data;
186 type Error = B::Error;
187
188 fn poll_frame(
189 self: Pin<&mut Self>,
190 cx: &mut Context<'_>,
191 ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
192 let mut me = self.project();
193
194 let poll_result = {
196 let Some(body) = me.body.as_mut() else {
197 return Poll::Ready(None);
199 };
200 Pin::new(body).poll_frame(cx)
201 };
202
203 let mut record_body_size_once = |body_size: u64| {
204 if let Some(span) = me.span.take() {
205 span.record(otel_attribute::HTTP_RESPONSE_BODY_SIZE, body_size);
206 }
207 };
208 match poll_result {
209 Poll::Ready(value) => {
210 me.sleep.as_mut().set(None);
211
212 match &value {
213 Some(Ok(frame)) => {
214 if let Some(data) = frame.data_ref() {
215 *me.byte_count += data.remaining() as u64;
216 }
217 if me.body.as_ref().is_some_and(|b| b.is_end_stream()) {
218 record_body_size_once(*me.byte_count);
219 }
220 }
221 None => {
222 record_body_size_once(*me.byte_count);
223 }
224 Some(Err(e)) => {
225 tracing::warn!("error reading response body: {e:?}");
226 }
227 }
228
229 Poll::Ready(value)
230 }
231 Poll::Pending => {
232 if me.sleep.is_none() {
233 me.sleep.as_mut().set(Some(tokio::time::sleep(*me.timeout)));
234 }
235 task::ready!(me.sleep.as_pin_mut().unwrap().poll(cx));
236
237 *me.body = None;
240 record_body_size_once(*me.byte_count);
241
242 Poll::Ready(Some(Err(WasiHttpError::ConnectionReadTimeout)))
243 }
244 }
245 }
246
247 fn is_end_stream(&self) -> bool {
248 self.body.as_ref().is_none_or(|b| b.is_end_stream())
249 }
250
251 fn size_hint(&self) -> SizeHint {
252 self.body
253 .as_ref()
254 .map(|b| b.size_hint())
255 .unwrap_or_default()
256 }
257}
258
259pub(crate) fn add_to_linker<C>(ctx: &mut C) -> anyhow::Result<()>
260where
261 C: spin_factors::InitContext<OutboundHttpFactor>,
262{
263 let linker = ctx.linker();
264
265 fn get_http<C>(store: &mut C::StoreData) -> WasiHttpCtxView<'_>
266 where
267 C: spin_factors::InitContext<OutboundHttpFactor>,
268 {
269 let (state, table) = C::get_data_with_table(store);
270 let ctx = &mut state.wasi_http_ctx;
271 WasiHttpCtxView {
272 ctx,
273 table,
274 hooks: &mut state.hooks,
275 }
276 }
277
278 let get_http = get_http::<C> as fn(&mut C::StoreData) -> WasiHttpCtxView<'_>;
279 wasmtime_wasi_http::p2::bindings::http::outgoing_handler::add_to_linker::<_, HasHttp>(
280 linker, get_http,
281 )?;
282 wasmtime_wasi_http::p2::bindings::http::types::add_to_linker::<_, HasHttp>(
283 linker,
284 &Default::default(),
285 get_http,
286 )?;
287
288 fn get_http_p3<C>(store: &mut C::StoreData) -> WasiHttpCtxView<'_>
289 where
290 C: spin_factors::InitContext<OutboundHttpFactor>,
291 {
292 let (state, table) = C::get_data_with_table(store);
293 let ctx = &mut state.wasi_http_ctx;
294 WasiHttpCtxView {
295 ctx,
296 table,
297 hooks: &mut state.hooks,
298 }
299 }
300
301 let get_http_p3 = get_http_p3::<C> as fn(&mut C::StoreData) -> WasiHttpCtxView<'_>;
302 p3::bindings::http::client::add_to_linker::<_, WasiHttp>(linker, get_http_p3)?;
303 p3::bindings::http::types::add_to_linker::<_, WasiHttp>(linker, get_http_p3)?;
304
305 wasi_2023_10_18::add_to_linker(linker, get_http)?;
306 wasi_2023_11_10::add_to_linker(linker, get_http)?;
307 wasi_2026_03_15::add_to_linker(linker, get_http_p3)?;
308
309 Ok(())
310}
311
312impl OutboundHttpFactor {
313 pub fn get_wasi_http_impl(
314 runtime_instance_state: &mut impl RuntimeFactorsInstanceState,
315 ) -> Option<WasiHttpCtxView<'_>> {
316 let (state, table) = runtime_instance_state.get_with_table::<OutboundHttpFactor>()?;
317 let ctx = &mut state.wasi_http_ctx;
318 Some(WasiHttpCtxView {
319 ctx,
320 table,
321 hooks: &mut state.hooks,
322 })
323 }
324}
325
326type OutgoingRequest = http::Request<HyperOutgoingBody>;
327
328struct RequestSender {
329 allowed_hosts: OutboundAllowedHosts,
330 blocked_networks: BlockedNetworks,
331 component_tls_configs: ComponentTlsClientConfigs,
332 self_request_origin: Option<SelfRequestOrigin>,
333 request_interceptor: Option<Arc<dyn OutboundHttpInterceptor>>,
334 http_clients: HttpClients,
335 semaphore: ConnectionSemaphore,
336}
337
338impl RequestSender {
339 async fn send(
340 self,
341 mut request: OutgoingRequest,
342 mut config: OutgoingRequestConfig,
343 ) -> Result<IncomingResponse, WasiHttpError> {
344 self.prepare_request(&mut request, &mut config).await?;
345
346 spin_telemetry::inject_trace_context(&mut request);
348
349 let mut override_connect_addr = None;
351 if let Some(interceptor) = &self.request_interceptor {
352 let intercept_request = std::mem::take(&mut request).into();
353 match interceptor
354 .intercept(intercept_request)
355 .await
356 .map_err(|e| match e.downcast() {
357 Ok(e) => WasiHttpError::from(e),
358 Err(_) => WasiHttpError::InternalError(None),
359 })? {
360 InterceptOutcome::Continue(mut req) => {
361 override_connect_addr = req.override_connect_addr.take();
362 request = req.into_hyper_request();
363 }
364 InterceptOutcome::Complete(resp) => {
365 let resp = IncomingResponse { resp, worker: None };
366 return Ok(resp);
367 }
368 }
369 }
370
371 let span = tracing::Span::current();
373 if let Some(addr) = override_connect_addr {
374 span.record(otel_attribute::SERVER_ADDRESS, addr.ip().to_string());
375 span.record(otel_attribute::SERVER_PORT, addr.port());
376 } else if let Some(authority) = request.uri().authority() {
377 span.record(otel_attribute::SERVER_ADDRESS, authority.host());
378 if let Some(port) = authority.port_u16() {
379 span.record(otel_attribute::SERVER_PORT, port);
380 }
381 }
382
383 record_content_length_header(
384 &span,
385 request.headers(),
386 "http.request.header.content-length",
387 );
388
389 Ok(self
390 .send_request(request, config, override_connect_addr)
391 .await?)
392 }
393
394 async fn prepare_request(
395 &self,
396 request: &mut OutgoingRequest,
397 config: &mut OutgoingRequestConfig,
398 ) -> Result<(), ErrorCode> {
399 let uri = request.uri_mut();
403 if uri
404 .authority()
405 .is_some_and(|authority| authority.host().is_empty())
406 {
407 let mut builder = http::uri::Builder::new();
408 if let Some(paq) = uri.path_and_query() {
409 builder = builder.path_and_query(paq.clone());
410 }
411 *uri = builder.build().unwrap();
412 }
413 tracing::Span::current().record(otel_attribute::URL_FULL, uri.to_string());
414
415 let is_self_request = match request.uri().authority() {
416 Some(authority) => authority.host() == "self.alt",
418 None => true,
420 };
421
422 let is_allowed = if is_self_request {
424 self.allowed_hosts
425 .check_relative_url(&["http", "https"])
426 .await
427 .unwrap_or(false)
428 } else {
429 self.allowed_hosts
430 .check_url(&request.uri().to_string(), "https")
431 .await
432 .unwrap_or(false)
433 };
434 if !is_allowed {
435 return Err(ErrorCode::HttpRequestDenied);
436 }
437
438 if is_self_request {
439 let Some(origin) = self.self_request_origin.as_ref() else {
441 tracing::error!(
442 "Couldn't handle outbound HTTP request to relative URI; no origin set"
443 );
444 return Err(ErrorCode::HttpRequestUriInvalid);
445 };
446
447 config.use_tls = origin.use_tls();
448
449 request.headers_mut().insert(HOST, origin.host_header());
450
451 let path_and_query = request.uri().path_and_query().cloned();
452 *request.uri_mut() = origin.clone().into_uri(path_and_query);
453 }
454
455 request.headers_mut().remove(HOST);
462 Ok(())
463 }
464
465 async fn send_request(
466 self,
467 request: OutgoingRequest,
468 config: OutgoingRequestConfig,
469 override_connect_addr: Option<SocketAddr>,
470 ) -> Result<IncomingResponse, ErrorCode> {
471 let OutgoingRequestConfig {
472 use_tls,
473 connect_timeout,
474 first_byte_timeout,
475 } = config;
476
477 let tls_client_config = if use_tls {
478 let host = request.uri().host().unwrap_or_default();
479 Some(self.component_tls_configs.get_client_config(host).clone())
480 } else {
481 None
482 };
483
484 let resp = CONNECT_OPTIONS.scope(
485 ConnectOptions {
486 blocked_networks: self.blocked_networks,
487 connect_timeout,
488 tls_client_config,
489 override_connect_addr,
490 semaphore: self.semaphore,
491 },
492 async move {
493 if use_tls {
494 self.http_clients.https.request(request).await
495 } else {
496 let h2c_prior_knowledge_host =
498 std::env::var("SPIN_OUTBOUND_H2C_PRIOR_KNOWLEDGE").ok();
499 let use_h2c = h2c_prior_knowledge_host.as_deref()
500 == request.uri().authority().map(|a| a.as_str());
501
502 if use_h2c {
503 self.http_clients.http2.request(request).await
504 } else {
505 self.http_clients.http1.request(request).await
506 }
507 }
508 },
509 );
510
511 let resp = timeout(first_byte_timeout, resp)
512 .await
513 .map_err(|_| ErrorCode::ConnectionReadTimeout)?
514 .map_err(hyper_legacy_request_error)?
515 .map(|body| {
516 body.map_err(hyper_request_error)
517 .map_err(WasiHttpError::from)
518 .boxed_unsync()
519 });
520
521 let span = tracing::Span::current();
522 span.record(
523 otel_attribute::HTTP_RESPONSE_STATUS_CODE,
524 resp.status().as_u16(),
525 );
526
527 record_content_length_header(&span, resp.headers(), "http.response.header.content-length");
528
529 Ok(IncomingResponse { resp, worker: None })
530 }
531}
532
533type HttpClient = Client<HttpConnector, HyperOutgoingBody>;
534type HttpsClient = Client<HttpsConnector, HyperOutgoingBody>;
535
536#[derive(Clone)]
537pub(super) struct HttpClients {
538 http1: HttpClient,
540 http2: HttpClient,
542 https: HttpsClient,
544}
545
546impl HttpClients {
547 pub(super) fn new(enable_pooling: bool) -> Self {
548 let builder = move || {
549 let mut builder = Client::builder(TokioExecutor::new());
550 if !enable_pooling {
551 builder.pool_max_idle_per_host(0);
552 }
553 builder
554 };
555 Self {
556 http1: builder().build(HttpConnector),
557 http2: builder().http2_only(true).build(HttpConnector),
558 https: builder().build(HttpsConnector),
559 }
560 }
561}
562
563tokio::task_local! {
564 static CONNECT_OPTIONS: ConnectOptions;
572}
573
574#[derive(Clone)]
575struct ConnectOptions {
576 blocked_networks: BlockedNetworks,
578 connect_timeout: Duration,
580 tls_client_config: Option<TlsClientConfig>,
582 override_connect_addr: Option<SocketAddr>,
584 semaphore: ConnectionSemaphore,
586}
587
588impl ConnectOptions {
589 async fn connect_tcp(
591 &self,
592 uri: &Uri,
593 default_port: u16,
594 ) -> Result<PermittedTcpStream, ErrorCode> {
595 let mut socket_addrs = match self.override_connect_addr {
596 Some(override_connect_addr) => vec![override_connect_addr],
597 None => {
598 let authority = uri.authority().ok_or(ErrorCode::HttpRequestUriInvalid)?;
599
600 let host_and_port = if authority.port().is_some() {
601 authority.as_str().to_string()
602 } else {
603 format!("{}:{}", authority.as_str(), default_port)
604 };
605
606 let socket_addrs = tokio::net::lookup_host(&host_and_port)
607 .await
608 .map_err(|err| {
609 tracing::debug!(?host_and_port, ?err, "Error resolving host");
610 dns_error("address not available".into(), 0)
611 })?
612 .collect::<Vec<_>>();
613 tracing::debug!(?host_and_port, ?socket_addrs, "Resolved host");
614 socket_addrs
615 }
616 };
617
618 crate::remove_blocked_addrs(&self.blocked_networks, &mut socket_addrs)?;
620
621 let connect = async {
622 let permit = self.semaphore.acquire().await;
624 (TcpStream::connect(&*socket_addrs).await, permit)
625 };
626
627 let (stream, permit) = timeout(self.connect_timeout, connect)
630 .await
631 .map_err(|_| ErrorCode::ConnectionTimeout)?;
632 let permit = permit.map_err(|_| ErrorCode::ConnectionLimitReached)?;
633 let stream = stream.map_err(|err| match err.kind() {
634 std::io::ErrorKind::AddrNotAvailable => dns_error("address not available".into(), 0),
635 _ => ErrorCode::ConnectionRefused,
636 })?;
637 Ok(PermittedTcpStream {
638 inner: stream,
639 _permit: permit,
640 })
641 }
642
643 async fn connect_tls(
645 &self,
646 uri: &Uri,
647 default_port: u16,
648 ) -> Result<TlsStream<PermittedTcpStream>, ErrorCode> {
649 let tcp_stream = self.connect_tcp(uri, default_port).await?;
650
651 let mut tls_client_config = self.tls_client_config.as_deref().unwrap().clone();
652 tls_client_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
653
654 let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_client_config));
655 let domain = rustls::pki_types::ServerName::try_from(uri.host().unwrap())
656 .map_err(|e| {
657 tracing::warn!("dns lookup error: {e:?}");
658 dns_error("invalid dns name".into(), 0)
659 })?
660 .to_owned();
661 connector.connect(domain, tcp_stream).await.map_err(|e| {
662 tracing::warn!("tls protocol error: {e:?}");
663 ErrorCode::TlsProtocolError
664 })
665 }
666}
667
668#[derive(Clone)]
670struct HttpConnector;
671
672impl HttpConnector {
673 async fn connect(uri: Uri) -> Result<TokioIo<PermittedTcpStream>, ErrorCode> {
674 let stream = CONNECT_OPTIONS.get().connect_tcp(&uri, 80).await?;
675 Ok(TokioIo::new(stream))
676 }
677}
678
679impl Service<Uri> for HttpConnector {
680 type Response = TokioIo<PermittedTcpStream>;
681 type Error = ErrorCode;
682 type Future =
683 Pin<Box<dyn Future<Output = Result<TokioIo<PermittedTcpStream>, ErrorCode>> + Send>>;
684
685 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
686 Poll::Ready(Ok(()))
687 }
688
689 fn call(&mut self, uri: Uri) -> Self::Future {
690 Box::pin(async move { Self::connect(uri).await })
691 }
692}
693
694#[derive(Clone)]
696struct HttpsConnector;
697
698impl HttpsConnector {
699 async fn connect(uri: Uri) -> Result<TokioIo<RustlsStream>, ErrorCode> {
700 let stream = CONNECT_OPTIONS.get().connect_tls(&uri, 443).await?;
701 Ok(TokioIo::new(RustlsStream(stream)))
702 }
703}
704
705impl Service<Uri> for HttpsConnector {
706 type Response = TokioIo<RustlsStream>;
707 type Error = ErrorCode;
708 type Future = Pin<Box<dyn Future<Output = Result<TokioIo<RustlsStream>, ErrorCode>> + Send>>;
709
710 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
711 Poll::Ready(Ok(()))
712 }
713
714 fn call(&mut self, uri: Uri) -> Self::Future {
715 Box::pin(async move { Self::connect(uri).await })
716 }
717}
718
719struct RustlsStream(TlsStream<PermittedTcpStream>);
720
721impl Connection for RustlsStream {
722 fn connected(&self) -> Connected {
723 if self.0.get_ref().1.alpn_protocol() == Some(b"h2") {
724 self.0.get_ref().0.connected().negotiated_h2()
725 } else {
726 self.0.get_ref().0.connected()
727 }
728 }
729}
730
731impl AsyncRead for RustlsStream {
732 fn poll_read(
733 self: Pin<&mut Self>,
734 cx: &mut Context<'_>,
735 buf: &mut ReadBuf<'_>,
736 ) -> Poll<Result<(), std::io::Error>> {
737 Pin::new(&mut self.get_mut().0).poll_read(cx, buf)
738 }
739}
740
741impl AsyncWrite for RustlsStream {
742 fn poll_write(
743 self: Pin<&mut Self>,
744 cx: &mut Context<'_>,
745 buf: &[u8],
746 ) -> Poll<Result<usize, std::io::Error>> {
747 Pin::new(&mut self.get_mut().0).poll_write(cx, buf)
748 }
749
750 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
751 Pin::new(&mut self.get_mut().0).poll_flush(cx)
752 }
753
754 fn poll_shutdown(
755 self: Pin<&mut Self>,
756 cx: &mut Context<'_>,
757 ) -> Poll<Result<(), std::io::Error>> {
758 Pin::new(&mut self.get_mut().0).poll_shutdown(cx)
759 }
760
761 fn poll_write_vectored(
762 self: Pin<&mut Self>,
763 cx: &mut Context<'_>,
764 bufs: &[IoSlice<'_>],
765 ) -> Poll<Result<usize, std::io::Error>> {
766 Pin::new(&mut self.get_mut().0).poll_write_vectored(cx, bufs)
767 }
768
769 fn is_write_vectored(&self) -> bool {
770 self.0.is_write_vectored()
771 }
772}
773
774struct PermittedTcpStream {
776 inner: TcpStream,
778 _permit: ConnectionPermit,
783}
784
785impl Connection for PermittedTcpStream {
786 fn connected(&self) -> Connected {
787 self.inner.connected()
788 }
789}
790
791impl AsyncRead for PermittedTcpStream {
792 fn poll_read(
793 self: Pin<&mut Self>,
794 cx: &mut Context<'_>,
795 buf: &mut ReadBuf<'_>,
796 ) -> Poll<std::io::Result<()>> {
797 Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
798 }
799}
800
801impl AsyncWrite for PermittedTcpStream {
802 fn poll_write(
803 self: Pin<&mut Self>,
804 cx: &mut Context<'_>,
805 buf: &[u8],
806 ) -> Poll<Result<usize, std::io::Error>> {
807 Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
808 }
809
810 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
811 Pin::new(&mut self.get_mut().inner).poll_flush(cx)
812 }
813
814 fn poll_shutdown(
815 self: Pin<&mut Self>,
816 cx: &mut Context<'_>,
817 ) -> Poll<Result<(), std::io::Error>> {
818 Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
819 }
820}
821
822fn hyper_request_error(err: hyper::Error) -> ErrorCode {
824 if let Some(cause) = err.source()
826 && let Some(err) = cause.downcast_ref::<ErrorCode>()
827 {
828 return err.clone();
829 }
830
831 tracing::warn!("hyper request error: {err:?}");
832
833 ErrorCode::HttpProtocolError
834}
835
836fn hyper_legacy_request_error(err: hyper_util::client::legacy::Error) -> ErrorCode {
838 if let Some(cause) = err.source()
840 && let Some(err) = cause.downcast_ref::<ErrorCode>()
841 {
842 return err.clone();
843 }
844
845 tracing::warn!("hyper request error: {err:?}");
846
847 ErrorCode::HttpProtocolError
848}
849
850fn dns_error(rcode: String, info_code: u16) -> ErrorCode {
851 ErrorCode::DnsError(
852 wasmtime_wasi_http::p2::bindings::http::types::DnsErrorPayload {
853 rcode: Some(rcode),
854 info_code: Some(info_code),
855 },
856 )
857}
858
859fn record_content_length_header(span: &Span, headers: &HeaderMap, attr_name: &'static str) {
860 if let Some(content_length) = headers.get(CONTENT_LENGTH)
861 && let Ok(size_str) = content_length.to_str()
862 {
863 span.set_attribute(attr_name, size_str.to_string());
864 }
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870 use spin_factor_outbound_networking::LimitedSemaphore;
871
872 #[tokio::test]
877 async fn connect_timeout_applies_to_permit_acquisition() {
878 let conn_semaphore = ConnectionSemaphore::new(
881 None,
882 Some(LimitedSemaphore::new(1)),
883 "test",
884 "app-id".into(),
885 None,
886 );
887 let _held = conn_semaphore
888 .try_acquire()
889 .expect("exhausting the single permit");
890
891 let options = ConnectOptions {
892 blocked_networks: BlockedNetworks::default(),
894 connect_timeout: Duration::from_millis(50),
896 tls_client_config: None,
897 override_connect_addr: Some("127.0.0.1:1".parse().unwrap()),
899 semaphore: conn_semaphore,
900 };
901
902 let result = options
905 .connect_tcp(&Uri::from_static("http://test.example"), 80)
906 .await;
907
908 assert!(
909 matches!(result, Err(ErrorCode::ConnectionTimeout)),
910 "expected ConnectionTimeout"
911 );
912 }
913}