Skip to main content

spin_factor_outbound_http/
wasi.rs

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        // If the caller (i.e. the guest) has trouble consuming the response
107        // (e.g. encountering a network error while forwarding it on to some
108        // other place), it can report that error to us via `fut`.  However,
109        // there's nothing we'll be able to do with it here, so we ignore it.
110        // Presumably the guest will also drop the body stream and trailers
111        // future if it encounters such an error while those things are still
112        // arriving, which Hyper will deal with as appropriate (e.g. closing the
113        // connection).
114        _ = 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                            // TODO: Can we plumb connection errors through to here, or
159                            // will `hyper_util::client::legacy::Client` pass them all
160                            // via the response body?
161                            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        // Wrapping `body` in `Option` lets us take it out (dropping the underlying connection) when the timeout fires.
175        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        // Scope the mutable borrow so we can touch `me.body` again below.
195        let poll_result = {
196            let Some(body) = me.body.as_mut() else {
197                // Body already dropped by a previous timeout fire.
198                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                // Drop the inner body immediately to free resources (like sockets)
238                // rather than waiting for the guest to release the resource.
239                *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        // If the current span has opentelemetry trace context, inject it into the request
347        spin_telemetry::inject_trace_context(&mut request);
348
349        // Run any configured request interceptor
350        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        // Backfill span fields after potentially updating the URL in the interceptor
372        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        // wasmtime-wasi-http fills in scheme and authority for relative URLs
400        // (e.g. https://:443/<path>), which makes them hard to reason about.
401        // Undo that here.
402        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 SDKs require an authority, so we support e.g. http://self.alt/self-request
417            Some(authority) => authority.host() == "self.alt",
418            // Otherwise self requests have no authority
419            None => true,
420        };
421
422        // Enforce allowed_outbound_hosts
423        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            // Replace the authority with the "self request origin"
440            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        // Some servers (looking at you nginx) don't like a host header even though
456        // http/2 allows it: https://github.com/hyperium/hyper/issues/3298.
457        //
458        // Note that we do this _before_ invoking the request interceptor.  It may
459        // decide to add the `host` header back in, regardless of the nginx bug, in
460        // which case we'll let it do so without interferring.
461        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                    // For development purposes, allow configuring plaintext HTTP/2 for a specific host.
497                    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    /// Used for non-TLS HTTP/1 connections.
539    http1: HttpClient,
540    /// Used for non-TLS HTTP/2 connections (e.g. when h2 prior knowledge is available).
541    http2: HttpClient,
542    /// Used for HTTP-over-TLS connections, using ALPN to negotiate the HTTP version.
543    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    /// The options used when establishing a new connection.
565    ///
566    /// We must use task-local variables for these config options when using
567    /// `hyper_util::client::legacy::Client::request` because there's no way to plumb
568    /// them through as parameters.  Moreover, if there's already a pooled connection
569    /// ready, we'll reuse that and ignore these options anyway. After each connection
570    /// is established, the options are dropped.
571    static CONNECT_OPTIONS: ConnectOptions;
572}
573
574#[derive(Clone)]
575struct ConnectOptions {
576    /// The blocked networks configuration.
577    blocked_networks: BlockedNetworks,
578    /// Timeout for establishing a TCP connection.
579    connect_timeout: Duration,
580    /// TLS client configuration to use, if any.
581    tls_client_config: Option<TlsClientConfig>,
582    /// If set, override the address to connect to instead of using the given `uri`'s authority.
583    override_connect_addr: Option<SocketAddr>,
584    /// Semaphore to limit concurrent outbound connections.
585    semaphore: ConnectionSemaphore,
586}
587
588impl ConnectOptions {
589    /// Establish a TCP connection to the given URI and default port.
590    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        // Remove blocked IPs
619        crate::remove_blocked_addrs(&self.blocked_networks, &mut socket_addrs)?;
620
621        let connect = async {
622            // If we're limiting concurrent outbound requests, acquire a permit
623            let permit = self.semaphore.acquire().await;
624            (TcpStream::connect(&*socket_addrs).await, permit)
625        };
626
627        // Make sure that the connect timeout applies to both acquiring the outbound request permit and establishing the TCP connection,
628        // since acquiring the permit could potentially take a long time if there are many outbound requests happening.
629        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    /// Establish a TLS connection to the given URI and default port.
644    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/// A connector the uses `ConnectOptions`
669#[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/// A connector that establishes TLS connections using `rustls` and `ConnectOptions`.
695#[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
774/// A TCP stream that holds a permit indicating that it is allowed to exist.
775struct PermittedTcpStream {
776    /// The wrapped TCP stream.
777    inner: TcpStream,
778    /// A permit indicating that this stream is allowed to exist.
779    ///
780    /// When this stream is dropped, the permit is also dropped, allowing another
781    /// connection to be established.
782    _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
822/// Translate a [`hyper::Error`] to a wasi-http `ErrorCode` in the context of a request.
823fn hyper_request_error(err: hyper::Error) -> ErrorCode {
824    // If there's a source, we might be able to extract a wasi-http error from it.
825    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
836/// Translate a [`hyper_util::client::legacy::Error`] to a wasi-http `ErrorCode` in the context of a request.
837fn hyper_legacy_request_error(err: hyper_util::client::legacy::Error) -> ErrorCode {
838    // If there's a source, we might be able to extract a wasi-http error from it.
839    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    /// Regression test: the connect timeout must cover permit acquisition, not
873    /// just the TCP handshake.  Before the fix, a fully-saturated semaphore
874    /// caused `connect_tcp` to hang indefinitely; after the fix it returns
875    /// `ConnectionTimeout` within the configured deadline.
876    #[tokio::test]
877    async fn connect_timeout_applies_to_permit_acquisition() {
878        // Create a semaphore with exactly 1 permit and immediately exhaust it, leaving
879        // 0 permits available.  This simulates all outbound-connection slots being occupied.
880        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            // No blocked networks; we want the address to pass the filter.
893            blocked_networks: BlockedNetworks::default(),
894            // A very short deadline so the test runs quickly.
895            connect_timeout: Duration::from_millis(50),
896            tls_client_config: None,
897            // Skip DNS by supplying the address directly.
898            override_connect_addr: Some("127.0.0.1:1".parse().unwrap()),
899            semaphore: conn_semaphore,
900        };
901
902        // `connect_tcp` must time out while waiting for a permit rather than
903        // blocking forever.
904        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}