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