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::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        // If the caller (i.e. the guest) has trouble consuming the response
106        // (e.g. encountering a network error while forwarding it on to some
107        // other place), it can report that error to us via `fut`.  However,
108        // there's nothing we'll be able to do with it here, so we ignore it.
109        // Presumably the guest will also drop the body stream and trailers
110        // future if it encounters such an error while those things are still
111        // arriving, which Hyper will deal with as appropriate (e.g. closing the
112        // connection).
113        _ = 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                            // TODO: Can we plumb connection errors through to here, or
162                            // will `hyper_util::client::legacy::Client` pass them all
163                            // via the response body?
164                            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        // Wrapping `body` in `Option` lets us take it out (dropping the underlying connection) when the timeout fires.
183        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        // Scope the mutable borrow so we can touch `me.body` again below.
203        let poll_result = {
204            let Some(body) = me.body.as_mut() else {
205                // Body already dropped by a previous timeout fire.
206                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                // Drop the inner body immediately to free resources (like sockets)
246                // rather than waiting for the guest to release the resource.
247                *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        // If the current span has opentelemetry trace context, inject it into the request
414        spin_telemetry::inject_trace_context(&mut request);
415
416        // Run any configured request interceptor
417        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        // Backfill span fields after potentially updating the URL in the interceptor
437        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        // wasmtime-wasi-http fills in scheme and authority for relative URLs
465        // (e.g. https://:443/<path>), which makes them hard to reason about.
466        // Undo that here.
467        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 SDKs require an authority, so we support e.g. http://self.alt/self-request
482            Some(authority) => authority.host() == "self.alt",
483            // Otherwise self requests have no authority
484            None => true,
485        };
486
487        // Enforce allowed_outbound_hosts
488        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            // Replace the authority with the "self request origin"
505            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        // Some servers (looking at you nginx) don't like a host header even though
521        // http/2 allows it: https://github.com/hyperium/hyper/issues/3298.
522        //
523        // Note that we do this _before_ invoking the request interceptor.  It may
524        // decide to add the `host` header back in, regardless of the nginx bug, in
525        // which case we'll let it do so without interferring.
526        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                    // For development purposes, allow configuring plaintext HTTP/2 for a specific host.
563                    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    /// Used for non-TLS HTTP/1 connections.
605    http1: HttpClient,
606    /// Used for non-TLS HTTP/2 connections (e.g. when h2 prior knowledge is available).
607    http2: HttpClient,
608    /// Used for HTTP-over-TLS connections, using ALPN to negotiate the HTTP version.
609    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    /// The options used when establishing a new connection.
631    ///
632    /// We must use task-local variables for these config options when using
633    /// `hyper_util::client::legacy::Client::request` because there's no way to plumb
634    /// them through as parameters.  Moreover, if there's already a pooled connection
635    /// ready, we'll reuse that and ignore these options anyway. After each connection
636    /// is established, the options are dropped.
637    static CONNECT_OPTIONS: ConnectOptions;
638}
639
640#[derive(Clone)]
641struct ConnectOptions {
642    /// The blocked networks configuration.
643    blocked_networks: BlockedNetworks,
644    /// Timeout for establishing a TCP connection.
645    connect_timeout: Duration,
646    /// TLS client configuration to use, if any.
647    tls_client_config: Option<TlsClientConfig>,
648    /// If set, override the address to connect to instead of using the given `uri`'s authority.
649    override_connect_addr: Option<SocketAddr>,
650    /// Semaphore to limit concurrent outbound connections.
651    semaphore: ConnectionSemaphore,
652}
653
654impl ConnectOptions {
655    /// Establish a TCP connection to the given URI and default port.
656    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        // Remove blocked IPs
685        crate::remove_blocked_addrs(&self.blocked_networks, &mut socket_addrs)?;
686
687        let connect = async {
688            // If we're limiting concurrent outbound requests, acquire a permit
689            let permit = self.semaphore.acquire().await;
690            (TcpStream::connect(&*socket_addrs).await, permit)
691        };
692
693        // Make sure that the connect timeout applies to both acquiring the outbound request permit and establishing the TCP connection,
694        // since acquiring the permit could potentially take a long time if there are many outbound requests happening.
695        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    /// Establish a TLS connection to the given URI and default port.
710    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/// A connector the uses `ConnectOptions`
735#[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/// A connector that establishes TLS connections using `rustls` and `ConnectOptions`.
761#[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
840/// A TCP stream that holds a permit indicating that it is allowed to exist.
841struct PermittedTcpStream {
842    /// The wrapped TCP stream.
843    inner: TcpStream,
844    /// A permit indicating that this stream is allowed to exist.
845    ///
846    /// When this stream is dropped, the permit is also dropped, allowing another
847    /// connection to be established.
848    _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
888/// Translate a [`hyper::Error`] to a wasi-http `ErrorCode` in the context of a request.
889fn hyper_request_error(err: hyper::Error) -> ErrorCode {
890    // If there's a source, we might be able to extract a wasi-http error from it.
891    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
902/// Translate a [`hyper_util::client::legacy::Error`] to a wasi-http `ErrorCode` in the context of a request.
903fn hyper_legacy_request_error(err: hyper_util::client::legacy::Error) -> ErrorCode {
904    // If there's a source, we might be able to extract a wasi-http error from it.
905    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    /// Regression test: the connect timeout must cover permit acquisition, not
939    /// just the TCP handshake.  Before the fix, a fully-saturated semaphore
940    /// caused `connect_tcp` to hang indefinitely; after the fix it returns
941    /// `ConnectionTimeout` within the configured deadline.
942    #[tokio::test]
943    async fn connect_timeout_applies_to_permit_acquisition() {
944        // Create a semaphore with exactly 1 permit and immediately exhaust it, leaving
945        // 0 permits available.  This simulates all outbound-connection slots being occupied.
946        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            // No blocked networks; we want the address to pass the filter.
959            blocked_networks: BlockedNetworks::default(),
960            // A very short deadline so the test runs quickly.
961            connect_timeout: Duration::from_millis(50),
962            tls_client_config: None,
963            // Skip DNS by supplying the address directly.
964            override_connect_addr: Some("127.0.0.1:1".parse().unwrap()),
965            semaphore: conn_semaphore,
966        };
967
968        // `connect_tcp` must time out while waiting for a permit rather than
969        // blocking forever.
970        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}