Skip to main content

connectrpc/client/
mod.rs

1//! Tower-based HTTP client transports for ConnectRPC.
2//!
3//! Generated `FooServiceClient<T>` structs are generic over
4//! `T: `[`ClientTransport`] — any tower-compatible HTTP client works. This
5//! module provides two concrete transports:
6//!
7//! | Transport | Protocol | Use when |
8//! |---|---|---|
9//! | [`SharedHttp2Connection`] | HTTP/2 only | **Default for gRPC.** Honest `poll_ready`, composes with `tower::balance`. |
10//! | [`HttpClient`] | HTTP/1.1 + HTTP/2 (ALPN) | Connect protocol over h/1.1, or you genuinely don't know which protocol the server speaks. |
11//!
12//! # For gRPC: `SharedHttp2Connection`
13//!
14//! gRPC mandates HTTP/2. Use [`Http2Connection`] (or its `Clone`-able
15//! [`SharedHttp2Connection`] wrapper), which holds a single raw h2 connection
16//! with a reconnect state machine and *honest* readiness reporting:
17//!
18//! ```rust,ignore
19//! use connectrpc::client::{Http2Connection, ClientConfig};
20//! use connectrpc::Protocol;
21//!
22//! let uri: http::Uri = "http://localhost:8080".parse()?;
23//! let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
24//! let config = ClientConfig::new(uri).with_protocol(Protocol::Grpc);
25//!
26//! // Generated clients take any T: ClientTransport — shared handle is cheap to clone.
27//! let greet = GreetServiceClient::new(conn.clone(), config.clone());
28//! let math  = MathServiceClient::new(conn.clone(), config.clone());
29//!
30//! let response = greet.greet(GreetRequest { name: "World".into() }).await?;
31//! ```
32//!
33//! ## Scaling past single-connection contention
34//!
35//! A single HTTP/2 connection has a throughput ceiling set by `h2`'s internal
36//! `Mutex<Inner>` ([h2 #531]) — typically ~30–40k req/s regardless of handler
37//! work. To scale past that, spread load across N connections:
38//!
39//! ```rust,ignore
40//! // Simple static round-robin: worker i uses connection i % N.
41//! let conns: Vec<_> = futures::future::try_join_all(
42//!     (0..8).map(|_| Http2Connection::connect_plaintext(uri.clone()))
43//! ).await?
44//!  .into_iter().map(|c| c.shared(1024)).collect();
45//!
46//! // Or: tower::balance::p2c::Balance + tower::load::PendingRequests
47//! // for dynamic load-aware routing. See the http2 module docs.
48//! ```
49//!
50//! Because `Http2Connection::poll_ready` honestly reports connection state
51//! (connecting / closed / ready), `tower::balance` can route around
52//! failed connections and p2c can make useful decisions.
53//!
54//! # For Connect over HTTP/1.1: `HttpClient`
55//!
56//! The Connect protocol works over both HTTP/1.1 and HTTP/2. If you need
57//! HTTP/1.1 (older reverse proxies, edge environments without h2c) or
58//! ALPN-based protocol negotiation for TLS connections, use [`HttpClient`]:
59//!
60//! ```rust,ignore
61//! use connectrpc::client::{HttpClient, ClientConfig};
62//!
63//! // Auto-negotiates HTTP/1.1 or HTTP/2 via ALPN (for https://) or uses
64//! // HTTP/1.1 by default for cleartext http://.
65//! let http = HttpClient::plaintext();
66//! let config = ClientConfig::new("http://localhost:8080".parse()?);
67//!
68//! let greet = GreetServiceClient::new(http.clone(), config);
69//! ```
70//!
71//! ## Caveats for `HttpClient` with `tower::balance`
72//!
73//! `HttpClient` wraps `hyper_util::client::legacy::Client`, whose `poll_ready`
74//! is **always `Ready(Ok)`** (it manages queueing and connection reuse
75//! internally). This means `tower::balance::p2c` has no real load signal and
76//! degrades to ~random selection. For HTTP/1.1 this is usually fine — the
77//! internal pool already load-balances across idle connections — but for
78//! HTTP/2 it pins all requests to a single shared connection with no way for
79//! balance to spread load. Prefer [`SharedHttp2Connection`] for that.
80//!
81//! # Tower middleware
82//!
83//! Both transports are `tower::Service`s, so standard layers compose:
84//!
85//! ```rust,ignore
86//! use tower::ServiceBuilder;
87//! use tower_http::timeout::TimeoutLayer;
88//!
89//! let conn = Http2Connection::connect_plaintext(uri).await?.shared(1024);
90//! let stacked = ServiceBuilder::new()
91//!     .layer(TimeoutLayer::new(Duration::from_secs(30)))
92//!     .service(conn);
93//!
94//! let client = GreetServiceClient::new(
95//!     connectrpc::client::ServiceTransport::new(stacked),
96//!     config,
97//! );
98//! ```
99//!
100//! [h2 #531]: https://github.com/hyperium/h2/issues/531
101
102use std::collections::HashMap;
103use std::marker::PhantomData;
104use std::pin::Pin;
105use std::time::Duration;
106
107use bytes::Bytes;
108use bytes::BytesMut;
109use http::Request;
110use http::Response;
111use http::Uri;
112use http_body::Body;
113use http_body_util::BodyExt;
114use http_body_util::Full;
115use http_body_util::combinators::BoxBody;
116
117use buffa::view::HasMessageView;
118use buffa::view::MessageView;
119use buffa::view::OwnedView;
120use buffa::view::ViewReborrow;
121/// Re-export of [`futures::Stream`] (the `futures` 0.3 / `futures-core` 0.3
122/// trait), which [`ClientRequestStream`] builds on. Re-exported so generic
123/// code can name the trait without a direct `futures` dependency.
124pub use futures::Stream;
125/// Re-export of [`futures::stream::iter`]: adapts a collection that is
126/// already in hand into a request stream for a client-streaming call,
127/// without a direct `futures` dependency.
128pub use futures::stream::iter as stream_iter;
129
130mod sealed {
131    pub trait Sealed {}
132    impl<S> Sealed for S where S: super::Stream + Send + 'static {}
133}
134
135/// The request-stream bound for client-streaming calls.
136///
137/// Implemented automatically for every `Stream<Item = Req> + Send + 'static`
138/// — it cannot (and never needs to) be implemented by hand. The trait exists
139/// so the compiler can point at the two usual fixes when the bound is not
140/// met: wrap a ready collection with [`stream_iter`], and make a borrowing
141/// stream yield owned messages (the stream backs the request body, which can
142/// outlive the call frame and move across threads — hence `Send + 'static`).
143///
144/// Not to be confused with the server-side
145/// [`dispatcher::RequestStream`](crate::dispatcher::RequestStream), a boxed
146/// stream of raw request bytes.
147///
148/// # Panics in `poll_next`
149///
150/// The stream backs the request body, so it is polled on the task driving
151/// the HTTP request rather than on the caller's. A panic in `poll_next`
152/// therefore does not propagate to the caller: it surfaces as a generic
153/// transport error, and where that driver task is shared between calls
154/// (such as [`SharedHttp2Connection`]) it can fault every RPC on that
155/// connection, not just this one. The stream yields `Req`, not a
156/// `Result`, so it has no channel for reporting its own failure: end the
157/// stream early instead of panicking, and surface the reason through your
158/// own application protocol.
159#[diagnostic::on_unimplemented(
160    message = "`{Self}` cannot be used as the request stream of a client-streaming call",
161    label = "expected an async `Stream<Item = {Req}> + Send + 'static`",
162    note = "for a collection that is already in hand, wrap it with `connectrpc::stream_iter(...)`",
163    note = "the stream backs the request body, so it must be `Send + 'static`: yield owned messages (no borrows of local data) or feed the call from a channel-backed stream"
164)]
165pub trait ClientRequestStream<Req>: sealed::Sealed + Stream<Item = Req> + Send + 'static {}
166
167impl<S, Req> ClientRequestStream<Req> for S where S: Stream<Item = Req> + Send + 'static {}
168
169use crate::codec::CodecFormat;
170use crate::codec::content_type;
171use crate::codec::encode_json;
172use crate::codec::header as connect_header;
173use crate::compression::CompressionPolicy;
174use crate::compression::CompressionRegistry;
175use crate::envelope::Envelope;
176use crate::error::ConnectError;
177use crate::error::ErrorCode;
178use crate::error::ErrorDetail;
179use crate::protocol::Protocol;
180use crate::protocol::hdr;
181
182/// Type alias for a boxed future, used in service implementations.
183pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
184
185/// The erased request body type accepted by [`ClientTransport::send`].
186///
187/// Non-streaming call sites construct this via [`full_body`]. Bidi streaming
188/// uses a channel-backed body so the request can be sent incrementally.
189pub type ClientBody = BoxBody<Bytes, ConnectError>;
190
191/// Wrap a fully-known buffer in a [`ClientBody`].
192///
193/// Used by unary and server-streaming calls where the complete request body
194/// is available before sending.
195#[inline]
196pub fn full_body(b: Bytes) -> ClientBody {
197    Full::new(b).map_err(|never| match never {}).boxed()
198}
199
200/// Walk an error's `source()` chain looking for a [`ConnectError`].
201///
202/// Boxed trait objects cannot appear as links in the chain: `Box<dyn Error>`
203/// does not itself implement `Error` (the blanket impl requires a sized
204/// type), so every link is a concrete error type and a plain `downcast_ref`
205/// at each link is exhaustive.
206fn find_connect_error_in_chain(
207    mut err: &(dyn std::error::Error + 'static),
208) -> Option<ConnectError> {
209    loop {
210        if let Some(connect_err) = err.downcast_ref::<ConnectError>() {
211            return Some(connect_err.clone());
212        }
213        err = err.source()?;
214    }
215}
216
217/// Build an `unavailable` [`ConnectError`] for a transport failure: the
218/// message is `"{context}: {err}"` (unchanged from before `source()` was
219/// tracked) and `err` is retained as the returned error's `source()`, so
220/// its cause (DNS failure, connection reset, TLS failure, timeout, ...)
221/// isn't lost even though only the `Display` text reaches the wire.
222///
223/// `err` is boxed once up front and reused for both the message and the
224/// source, rather than boxing again inside `with_source`.
225fn unavailable_from_transport_error(
226    context: impl std::fmt::Display,
227    err: impl Into<Box<dyn std::error::Error + Send + Sync>>,
228) -> ConnectError {
229    let err = err.into();
230    let message = format!("{context}: {err}");
231    ConnectError::unavailable(message).with_source(err)
232}
233
234/// Map a [`ClientTransport::send`] failure into the error surfaced to the
235/// caller.
236///
237/// Policy: a [`ConnectError`] found anywhere in the transport error's source
238/// chain is returned verbatim (preserving its code, message, details, and
239/// attached metadata; any outer wrappers' `Display` context is dropped).
240/// Both built-in transports already produce classified `ConnectError`s
241/// directly, so for them `context` never appears in the surfaced error.
242/// Errors with no `ConnectError` in their chain are wrapped as `unavailable`
243/// with the `context` prefix; the original error is retained as the
244/// returned `ConnectError`'s `source()` so its cause (DNS failure,
245/// connection reset, timeout, ...) isn't lost.
246fn map_transport_send_error<E>(err: E, context: &str) -> ConnectError
247where
248    E: std::error::Error + Send + Sync + 'static,
249{
250    find_connect_error_in_chain(&err)
251        .unwrap_or_else(|| unavailable_from_transport_error(context, err))
252}
253
254/// Extra slack added to client-side response buffer caps beyond the message
255/// size itself, to accommodate gRPC-Web trailer frames (which arrive as a
256/// separate 0x80-flagged body frame, not a standard envelope). 64 KiB is
257/// generous: the gRPC best-practices guide recommends keeping metadata
258/// under 8 KiB per header set.
259const RESPONSE_BUFFER_TRAILER_SLACK: usize = 64 * 1024;
260
261/// Return the end offset of a complete gRPC-Web trailer frame, if present.
262fn grpc_web_trailer_frame_end(data: &[u8]) -> Option<usize> {
263    let mut offset = 0;
264
265    while data.len().saturating_sub(offset) >= crate::envelope::HEADER_SIZE {
266        let length = u32::from_be_bytes([
267            data[offset + 1],
268            data[offset + 2],
269            data[offset + 3],
270            data[offset + 4],
271        ]) as usize;
272        let frame_end = offset
273            .checked_add(crate::envelope::HEADER_SIZE)?
274            .checked_add(length)?;
275        if frame_end > data.len() {
276            return None;
277        }
278        if data[offset] & 0x80 != 0 {
279            return Some(frame_end);
280        }
281        offset = frame_end;
282    }
283
284    None
285}
286
287/// Trait for types that can be used as ConnectRPC client transports.
288///
289/// This is automatically implemented for any `tower::Service` that handles
290/// HTTP requests with compatible body types.
291pub trait ClientTransport: Clone + Send + Sync + 'static {
292    /// The response body type.
293    type ResponseBody: Body<Data = Bytes> + Send + 'static;
294    /// The error type.
295    ///
296    /// If a [`ConnectError`] appears anywhere in this error's `source()`
297    /// chain (or is the error itself), the client call paths surface it to
298    /// the caller verbatim — code, message, details, and attached metadata —
299    /// discarding any outer wrappers' `Display` context. A transport can use
300    /// this to control the surfaced error classification, for example
301    /// returning `deadline_exceeded` from a timeout middleware. Errors with
302    /// no `ConnectError` in their chain are wrapped as `unavailable`.
303    type Error: std::error::Error + Send + Sync + 'static;
304
305    /// Send an HTTP request and receive a response.
306    fn send(
307        &self,
308        request: Request<ClientBody>,
309    ) -> BoxFuture<'static, Result<Response<Self::ResponseBody>, Self::Error>>;
310}
311
312/// Wrapper that implements `ClientTransport` for any compatible tower service.
313#[derive(Clone)]
314pub struct ServiceTransport<S> {
315    service: S,
316}
317
318impl<S> ServiceTransport<S> {
319    /// Create a new service transport.
320    pub fn new(service: S) -> Self {
321        Self { service }
322    }
323
324    /// Get a reference to the inner service.
325    pub fn inner(&self) -> &S {
326        &self.service
327    }
328
329    /// Get a mutable reference to the inner service.
330    pub fn inner_mut(&mut self) -> &mut S {
331        &mut self.service
332    }
333
334    /// Consume this transport and return the inner service.
335    pub fn into_inner(self) -> S {
336        self.service
337    }
338}
339
340impl<S, ResBody> ClientTransport for ServiceTransport<S>
341where
342    S: tower::Service<Request<ClientBody>, Response = Response<ResBody>>
343        + Clone
344        + Send
345        + Sync
346        + 'static,
347    S::Error: std::error::Error + Send + Sync + 'static,
348    S::Future: Send + 'static,
349    ResBody: Body<Data = Bytes> + Send + 'static,
350    ResBody::Error: std::error::Error + Send + Sync + 'static,
351{
352    type ResponseBody = ResBody;
353    type Error = S::Error;
354
355    fn send(
356        &self,
357        request: Request<ClientBody>,
358    ) -> BoxFuture<'static, Result<Response<Self::ResponseBody>, Self::Error>> {
359        // Use ServiceExt::oneshot to satisfy the tower contract: poll_ready()
360        // must return Ready(Ok(())) before call(). Many services (buffered,
361        // rate-limited, concurrency-limited) panic or deadlock if call() is
362        // invoked without readiness. oneshot handles this handshake correctly.
363        use tower::ServiceExt;
364        let service = self.service.clone();
365        Box::pin(service.oneshot(request))
366    }
367}
368
369// Raw HTTP/2 connection transport — see module docs for when to use vs HttpClient.
370#[cfg(feature = "client")]
371mod http2;
372#[cfg(feature = "client")]
373#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
374pub use http2::Http2Connection;
375#[cfg(feature = "client")]
376#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
377pub use http2::Http2ConnectionBuilder;
378#[cfg(feature = "client")]
379#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
380pub use http2::SharedHttp2Connection;
381#[cfg(feature = "client")]
382#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
383pub use http2::{DEFAULT_ESTABLISHMENT_TIMEOUT, DEFAULT_TCP_CONNECT_TIMEOUT};
384
385/// General-purpose HTTP client supporting both HTTP/1.1 and HTTP/2.
386///
387/// This wraps `hyper_util::client::legacy::Client` with sensible defaults.
388/// It auto-negotiates HTTP/1.1 vs HTTP/2 via ALPN (for `https://`) or
389/// defaults to HTTP/1.1 for cleartext `http://`.
390///
391/// # When to use this
392///
393/// - **Connect protocol over HTTP/1.1** (the main use case)
394/// - You genuinely don't know whether the server speaks h/1.1 or h/2
395/// - You want ALPN-based protocol negotiation for TLS
396///
397/// # When NOT to use this
398///
399/// For **gRPC** (which mandates HTTP/2), prefer [`SharedHttp2Connection`]:
400///
401/// - `HttpClient`'s `poll_ready` is always `Ready` (internal pool/queue) —
402///   `tower::balance` degrades to random selection.
403/// - For HTTP/2, the internal pool holds exactly ONE shared connection —
404///   all requests contend on a single h2 `Mutex<Inner>`, creating a
405///   throughput ceiling at ~30–40k req/s.
406///
407/// [`Http2Connection`] has honest `poll_ready` and is a single connection
408/// by design, so you can create N of them and balance across them properly.
409///
410/// Available when the `client` feature is enabled.
411#[cfg(feature = "client")]
412#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
413#[derive(Clone)]
414pub struct HttpClient {
415    inner: HttpClientInner,
416}
417
418// Manual impl: hyper's `Client` doesn't impl `Debug`. Print the mode so
419// tests can identify which transport variant unexpectedly succeeded.
420#[cfg(feature = "client")]
421#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
422impl std::fmt::Debug for HttpClient {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        let mode = match self.inner {
425            HttpClientInner::Plain(_) => "plaintext",
426            #[cfg(feature = "client-tls")]
427            HttpClientInner::Tls(_) => "tls",
428        };
429        f.debug_struct("HttpClient").field("mode", &mode).finish()
430    }
431}
432
433/// Inner hyper client, parameterized over connector type via an enum.
434///
435/// Both connectors are wrapped in [`TimeoutConnector`] so an optional
436/// `establishment_timeout` can bound the whole connector establishment. When no
437/// timeout is set the wrapper forwards each connect unchanged (a single boxed
438/// future per *connection*, not per request — negligible).
439#[cfg(feature = "client")]
440#[derive(Clone)]
441enum HttpClientInner {
442    /// Plaintext HTTP (http:// only). Rejects https:// at send-time.
443    Plain(
444        hyper_util::client::legacy::Client<
445            TimeoutConnector<hyper_util::client::legacy::connect::HttpConnector>,
446            ClientBody,
447        >,
448    ),
449    /// TLS HTTP (https:// only). hyper-rustls's https_only mode rejects
450    /// http:// at the connector level.
451    #[cfg(feature = "client-tls")]
452    Tls(
453        hyper_util::client::legacy::Client<
454            TimeoutConnector<
455                hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
456            >,
457            ClientBody,
458        >,
459    ),
460}
461
462/// A `tower::Service<Uri>` connector wrapper that bounds connection
463/// establishment with an optional timeout.
464///
465/// Wraps the built-in `HttpConnector` (plaintext) or hyper-rustls's
466/// `HttpsConnector` (TLS). When `timeout` is `Some`, a connect that doesn't
467/// resolve in time is cancelled (dropping the in-flight TCP/TLS work) and
468/// surfaced as an `unavailable` error. When `None`, the inner connector's
469/// future is awaited unchanged.
470#[cfg(feature = "client")]
471#[derive(Clone)]
472struct TimeoutConnector<C> {
473    inner: C,
474    timeout: Option<Duration>,
475}
476
477#[cfg(feature = "client")]
478impl<C> tower::Service<Uri> for TimeoutConnector<C>
479where
480    C: tower::Service<Uri>,
481    C::Error: Into<Box<dyn std::error::Error + Send + Sync>> + 'static,
482    C::Future: Send + 'static,
483    C::Response: Send + 'static,
484{
485    type Response = C::Response;
486    type Error = Box<dyn std::error::Error + Send + Sync>;
487    type Future = BoxFuture<'static, Result<C::Response, Self::Error>>;
488
489    fn poll_ready(
490        &mut self,
491        cx: &mut std::task::Context<'_>,
492    ) -> std::task::Poll<Result<(), Self::Error>> {
493        self.inner.poll_ready(cx).map_err(Into::into)
494    }
495
496    fn call(&mut self, uri: Uri) -> Self::Future {
497        let fut = self.inner.call(uri);
498        let timeout = self.timeout;
499        Box::pin(http2::run_establishment(fut, timeout))
500    }
501}
502
503#[cfg(feature = "client")]
504#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
505impl HttpClient {
506    /// Returns a builder for configuring connector-level options before
507    /// choosing a transport flavour.
508    ///
509    /// `HttpClient::plaintext()` is equivalent to
510    /// `HttpClient::builder().plaintext()`, and likewise for the other
511    /// constructors.
512    pub fn builder() -> HttpClientBuilder {
513        HttpClientBuilder::default()
514    }
515
516    /// Create a **plaintext** HTTP client. Only for `http://` URIs.
517    ///
518    /// Errors at send-time if given an `https://` URI — use
519    /// [`with_tls`](Self::with_tls) for TLS.
520    ///
521    /// The client uses connection pooling and supports HTTP/1.1 and HTTP/2
522    /// over cleartext. TCP_NODELAY is enabled to avoid Nagle + delayed ACK
523    /// latency on small messages.
524    ///
525    /// Connection establishment is bounded by [`DEFAULT_ESTABLISHMENT_TIMEOUT`]
526    /// (and [`DEFAULT_TCP_CONNECT_TIMEOUT`] per address); use
527    /// [`builder()`](Self::builder) to adjust or opt out.
528    pub fn plaintext() -> Self {
529        Self::builder().plaintext()
530    }
531
532    /// Create a **plaintext** HTTP client with HTTP/2 prior-knowledge (h2c) only.
533    ///
534    /// Only for `http://` URIs. Errors at send-time on `https://`.
535    /// For **TLS + HTTP/2-only** (e.g. gRPC over TLS), use
536    /// [`Http2Connection::connect_tls`] instead — there is no TLS equivalent
537    /// of this constructor.
538    ///
539    /// Uses HTTP/2 prior knowledge for cleartext connections. Required for
540    /// gRPC over cleartext (gRPC mandates HTTP/2).
541    ///
542    /// **Note:** For gRPC, prefer [`SharedHttp2Connection`] over this —
543    /// it has honest `poll_ready` and composes with `tower::balance`. This
544    /// method pins you to one connection per host with no way to scale out.
545    ///
546    /// Connection establishment is bounded by [`DEFAULT_ESTABLISHMENT_TIMEOUT`]
547    /// (and [`DEFAULT_TCP_CONNECT_TIMEOUT`] per address); use
548    /// [`builder()`](Self::builder) to adjust or opt out.
549    pub fn plaintext_http2_only() -> Self {
550        Self::builder().plaintext_http2_only()
551    }
552
553    /// Create a **TLS** HTTP client. Only for `https://` URIs.
554    ///
555    /// Errors at send-time if given an `http://` URI — use
556    /// [`plaintext`](Self::plaintext) for cleartext.
557    ///
558    /// ALPN is set to `["h2", "http/1.1"]` for HTTP/2-with-fallback
559    /// auto-negotiation. TCP_NODELAY is enabled.
560    ///
561    /// # Certificate rotation
562    ///
563    /// The config may contain a custom `ResolvesClientCert` for dynamic
564    /// cert rotation. `rustls::ClientConfig` stores the resolver as
565    /// `Arc<dyn ResolvesClientCert>`, so when this function clones the
566    /// config to set ALPN, the **same** resolver instance is shared — a
567    /// background rotation task holding its own `Arc` continues working.
568    ///
569    /// # Example
570    ///
571    /// ```rust,ignore
572    /// use connectrpc::client::HttpClient;
573    /// use connectrpc::rustls;
574    /// use std::sync::Arc;
575    ///
576    /// let tls_config = Arc::new(
577    ///     rustls::ClientConfig::builder()
578    ///         .with_root_certificates(roots)
579    ///         .with_no_client_auth(),
580    /// );
581    ///
582    /// let http = HttpClient::with_tls(tls_config);
583    /// let client = GreetServiceClient::new(http, config);
584    /// ```
585    ///
586    /// Connection establishment is bounded by [`DEFAULT_ESTABLISHMENT_TIMEOUT`]
587    /// (and [`DEFAULT_TCP_CONNECT_TIMEOUT`] per address); use
588    /// [`builder()`](Self::builder) to adjust or opt out.
589    #[cfg(feature = "client-tls")]
590    #[cfg_attr(docsrs, doc(cfg(feature = "client-tls")))]
591    pub fn with_tls(tls_config: std::sync::Arc<rustls::ClientConfig>) -> Self {
592        Self::builder().with_tls(tls_config)
593    }
594}
595
596/// Builder for [`HttpClient`] connector-level options.
597///
598/// Use [`HttpClient::builder`] to obtain one. The terminal methods mirror the
599/// associated constructors on `HttpClient`; the existing constructors delegate
600/// here, so `HttpClient::plaintext()` is exactly `HttpClient::builder().plaintext()`.
601#[cfg(feature = "client")]
602#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
603#[derive(Debug, Clone)]
604#[must_use = "call a terminal (plaintext / plaintext_http2_only / with_tls) to build the client"]
605pub struct HttpClientBuilder {
606    tcp_connect_timeout: Option<Duration>,
607    establishment_timeout: Option<Duration>,
608}
609
610#[cfg(feature = "client")]
611impl Default for HttpClientBuilder {
612    /// A fresh builder with [`DEFAULT_ESTABLISHMENT_TIMEOUT`] /
613    /// [`DEFAULT_TCP_CONNECT_TIMEOUT`] applied, so a hung server cannot stall
614    /// connection establishment indefinitely. Use
615    /// [`no_establishment_timeout`](HttpClientBuilder::no_establishment_timeout) /
616    /// [`no_tcp_connect_timeout`](HttpClientBuilder::no_tcp_connect_timeout) to
617    /// opt out.
618    fn default() -> Self {
619        Self {
620            tcp_connect_timeout: Some(DEFAULT_TCP_CONNECT_TIMEOUT),
621            establishment_timeout: Some(DEFAULT_ESTABLISHMENT_TIMEOUT),
622        }
623    }
624}
625
626#[cfg(feature = "client")]
627#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
628impl HttpClientBuilder {
629    /// Bound the TCP connect phase.
630    ///
631    /// This is hyper's [`HttpConnector::set_connect_timeout`][hyper-ct],
632    /// applied to the inner connector for all three transport flavours. It
633    /// covers only the TCP `connect(2)` call (per resolved address — hyper
634    /// divides the timeout evenly across the address set). It does **not**
635    /// cover DNS resolution or, for [`with_tls`](Self::with_tls), the TLS
636    /// handshake — set [`establishment_timeout`](Self::establishment_timeout) too to
637    /// bound those. Use a per-request timeout (e.g.
638    /// [`CallOptions::with_timeout`]) to bound DNS+connect+TLS+request as a
639    /// whole.
640    ///
641    /// Defaults to [`DEFAULT_TCP_CONNECT_TIMEOUT`]. To disable, use
642    /// [`no_tcp_connect_timeout`](Self::no_tcp_connect_timeout). Passing
643    /// `Duration::ZERO` causes every per-address connect to fail immediately.
644    ///
645    /// [hyper-ct]: hyper_util::client::legacy::connect::HttpConnector::set_connect_timeout
646    #[doc(alias = "connect_timeout")]
647    pub fn tcp_connect_timeout(mut self, dur: Duration) -> Self {
648        self.tcp_connect_timeout = http2::finite(dur);
649        self
650    }
651
652    /// Alias for [`tcp_connect_timeout`](Self::tcp_connect_timeout).
653    pub fn connect_timeout(self, dur: Duration) -> Self {
654        self.tcp_connect_timeout(dur)
655    }
656
657    /// Disable the per-address TCP connect bound (the
658    /// [`DEFAULT_TCP_CONNECT_TIMEOUT`] default). The whole-connector
659    /// [`establishment_timeout`](Self::establishment_timeout) still applies.
660    pub fn no_tcp_connect_timeout(mut self) -> Self {
661        self.tcp_connect_timeout = None;
662        self
663    }
664
665    /// Bound the whole connector establishment: DNS resolution, the TCP connect,
666    /// and, for [`with_tls`](Self::with_tls), the TLS handshake.
667    ///
668    /// Unlike [`tcp_connect_timeout`](Self::tcp_connect_timeout) (which bounds only the
669    /// per-address TCP `connect(2)` call), this is a single wall-clock bound on
670    /// everything the connector does to produce a usable stream — so on the TLS
671    /// transport the two bounds overlap on the TCP phase.
672    ///
673    /// # What it does and does not cover
674    ///
675    /// Because `HttpClient` pools connections through hyper's legacy client, the
676    /// HTTP/2 preface runs *inside* the pool and is not separately observable
677    /// here — this bound covers **DNS, TCP and TLS, not the h2 preface**.
678    /// [`Http2Connection`]'s handshake bound additionally covers the h2
679    /// preface. Use a per-request timeout (e.g.
680    /// [`CallOptions::with_timeout`]) for a true end-to-end bound. For a
681    /// transport that bounds the h2 preface too, use [`Http2Connection`].
682    ///
683    /// Exceeding this bound surfaces as a [`ConnectError`] with
684    /// [`ErrorCode::Unavailable`] (the connect is retryable); the message names
685    /// the establishment phase.
686    ///
687    /// Defaults to [`DEFAULT_ESTABLISHMENT_TIMEOUT`]. To disable, use
688    /// [`no_establishment_timeout`](Self::no_establishment_timeout). Passing
689    /// `Duration::ZERO` causes every establishment to fail immediately.
690    pub fn establishment_timeout(mut self, dur: Duration) -> Self {
691        self.establishment_timeout = http2::finite(dur);
692        self
693    }
694
695    /// Disable the wall-clock connector-establishment bound (the
696    /// [`DEFAULT_ESTABLISHMENT_TIMEOUT`] default). With both this and
697    /// [`no_tcp_connect_timeout`](Self::no_tcp_connect_timeout), a hung server
698    /// can stall connection establishment indefinitely — the pre-0.8.0
699    /// behaviour.
700    pub fn no_establishment_timeout(mut self) -> Self {
701        self.establishment_timeout = None;
702        self
703    }
704
705    fn http_connector(&self) -> hyper_util::client::legacy::connect::HttpConnector {
706        let mut connector = hyper_util::client::legacy::connect::HttpConnector::new();
707        connector.set_nodelay(true);
708        connector.set_connect_timeout(self.tcp_connect_timeout);
709        connector
710    }
711
712    /// Wrap a connector so `establishment_timeout` (if set) bounds its establishment.
713    fn wrap<C>(&self, connector: C) -> TimeoutConnector<C> {
714        TimeoutConnector {
715            inner: connector,
716            timeout: self.establishment_timeout,
717        }
718    }
719
720    /// Finish building as a plaintext client. See [`HttpClient::plaintext`].
721    #[must_use]
722    pub fn plaintext(self) -> HttpClient {
723        use hyper_util::client::legacy::Client;
724        use hyper_util::rt::TokioExecutor;
725
726        let connector = self.wrap(self.http_connector());
727        let client = Client::builder(TokioExecutor::new()).build(connector);
728        HttpClient {
729            inner: HttpClientInner::Plain(client),
730        }
731    }
732
733    /// Finish building as an h2c-only plaintext client. See
734    /// [`HttpClient::plaintext_http2_only`].
735    #[must_use]
736    pub fn plaintext_http2_only(self) -> HttpClient {
737        use hyper_util::client::legacy::Client;
738        use hyper_util::rt::TokioExecutor;
739
740        let connector = self.wrap(self.http_connector());
741        let client = Client::builder(TokioExecutor::new())
742            .http2_only(true)
743            .build(connector);
744        HttpClient {
745            inner: HttpClientInner::Plain(client),
746        }
747    }
748
749    /// Finish building as a TLS client. See [`HttpClient::with_tls`].
750    #[cfg(feature = "client-tls")]
751    #[cfg_attr(docsrs, doc(cfg(feature = "client-tls")))]
752    #[must_use]
753    pub fn with_tls(self, tls_config: std::sync::Arc<rustls::ClientConfig>) -> HttpClient {
754        use hyper_util::client::legacy::Client;
755        use hyper_util::rt::TokioExecutor;
756
757        let mut http = self.http_connector();
758        // HttpConnector rejects https:// by default; disable so the scheme
759        // passes through to the HttpsConnector for TLS handling.
760        http.enforce_http(false);
761
762        // Clone config to set ALPN. The inner Arc<dyn ResolvesClientCert>
763        // is shared through the clone — cert rotation is unaffected.
764        // hyper-rustls's builder requires alpn_protocols to be EMPTY on
765        // input (it sets them based on enable_http1/enable_http2), so if
766        // the caller already set ALPN, clear it first.
767        let mut cfg = (*tls_config).clone();
768        cfg.alpn_protocols.clear();
769
770        // Builder in https_only mode rejects http:// at the connector level
771        // (force_https = true), backing up our send()-time scheme check.
772        // enable_all_versions sets ALPN = [h2, http/1.1].
773        let https = hyper_rustls::HttpsConnectorBuilder::new()
774            .with_tls_config(cfg)
775            .https_only()
776            .enable_all_versions()
777            .wrap_connector(http);
778
779        let connector = self.wrap(https);
780        let client = Client::builder(TokioExecutor::new()).build(connector);
781        HttpClient {
782            inner: HttpClientInner::Tls(client),
783        }
784    }
785}
786
787// No `Default` impl for HttpClient — there's no sensible default when the
788// choice between plaintext and TLS is security-relevant. Users must
789// explicitly choose plaintext() or with_tls().
790
791#[cfg(feature = "client")]
792#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
793impl ClientTransport for HttpClient {
794    type ResponseBody = hyper::body::Incoming;
795    type Error = ConnectError;
796
797    fn send(
798        &self,
799        request: Request<ClientBody>,
800    ) -> BoxFuture<'static, Result<Response<Self::ResponseBody>, Self::Error>> {
801        let scheme = request.uri().scheme_str();
802
803        match &self.inner {
804            HttpClientInner::Plain(client) => {
805                // Plaintext variant: reject https:// to prevent accidental
806                // cleartext connections to TLS endpoints.
807                if scheme == Some("https") {
808                    return Box::pin(async {
809                        Err(ConnectError::invalid_argument(
810                            "HttpClient::plaintext() received https:// URI; \
811                             use HttpClient::with_tls for TLS",
812                        ))
813                    });
814                }
815                let client = client.clone();
816                Box::pin(async move {
817                    client
818                        .request(request)
819                        .await
820                        .map_err(|e| unavailable_from_transport_error("HTTP request failed", e))
821                })
822            }
823            #[cfg(feature = "client-tls")]
824            HttpClientInner::Tls(client) => {
825                // TLS variant: reject http:// — user explicitly chose TLS,
826                // silently falling back to cleartext is a security footgun.
827                if scheme == Some("http") {
828                    return Box::pin(async {
829                        Err(ConnectError::invalid_argument(
830                            "HttpClient::with_tls() received http:// URI; \
831                             use HttpClient::plaintext for cleartext",
832                        ))
833                    });
834                }
835                let client = client.clone();
836                Box::pin(async move {
837                    client
838                        .request(request)
839                        .await
840                        .map_err(|e| unavailable_from_transport_error("HTTPS request failed", e))
841                })
842            }
843        }
844    }
845}
846
847/// Configuration for a ConnectRPC client.
848///
849/// Construct with [`ClientConfig::new`] and the `with_*` builder methods,
850/// then read settings back through the accessor methods of the same name:
851///
852/// ```rust
853/// use connectrpc::client::ClientConfig;
854/// use connectrpc::Protocol;
855///
856/// let config = ClientConfig::new("http://localhost:8080".parse().unwrap())
857///     .with_protocol(Protocol::Grpc);
858/// assert_eq!(config.protocol(), Protocol::Grpc);
859/// ```
860///
861/// `ClientConfig` is `#[non_exhaustive]`: new fields may be added in minor
862/// releases. Struct-literal and functional-update construction are not
863/// available outside the crate; use the builder methods.
864#[derive(Clone, Debug)]
865#[non_exhaustive]
866pub struct ClientConfig {
867    pub(crate) base_uri: Uri,
868    pub(crate) protocol: Protocol,
869    pub(crate) codec_format: CodecFormat,
870    pub(crate) compression: CompressionRegistry,
871    pub(crate) request_compression: Option<String>,
872    pub(crate) compression_policy: CompressionPolicy,
873    pub(crate) default_timeout: Option<Duration>,
874    pub(crate) default_max_message_size: Option<usize>,
875    pub(crate) default_headers: http::HeaderMap,
876}
877
878impl ClientConfig {
879    /// Create a new client configuration with the given base URI.
880    ///
881    /// Uses Connect protocol with protobuf encoding by default.
882    pub fn new(base_uri: Uri) -> Self {
883        Self {
884            base_uri,
885            protocol: Protocol::Connect,
886            codec_format: CodecFormat::Proto,
887            compression: CompressionRegistry::default(),
888            request_compression: None,
889            compression_policy: CompressionPolicy::default(),
890            default_timeout: None,
891            default_max_message_size: None,
892            default_headers: http::HeaderMap::new(),
893        }
894    }
895
896    // ---- builders ---------------------------------------------------------
897
898    /// Set the wire protocol (Connect, gRPC, or gRPC-Web).
899    ///
900    /// Read via [`Self::protocol`].
901    #[must_use]
902    pub fn with_protocol(mut self, protocol: Protocol) -> Self {
903        self.protocol = protocol;
904        self
905    }
906
907    /// Set the codec format (proto or json).
908    ///
909    /// Read via [`Self::codec_format`].
910    ///
911    /// In a proto-only build (the `json` feature disabled) selecting
912    /// [`CodecFormat::Json`] produces a client whose every RPC returns
913    /// [`Unimplemented`](crate::ErrorCode::Unimplemented) before any
914    /// network I/O — the JSON codec is not compiled in. Prefer the default
915    /// [`CodecFormat::Proto`]; the [`json`](Self::json) shorthand is removed
916    /// from the API entirely in that build.
917    #[must_use]
918    pub fn with_codec_format(mut self, format: CodecFormat) -> Self {
919        self.codec_format = format;
920        self
921    }
922
923    /// Use JSON encoding. Shorthand for `with_codec_format(CodecFormat::Json)`.
924    ///
925    /// Only available when the `json` feature is enabled; a proto-only build
926    /// omits it so JSON cannot be selected through this shorthand.
927    #[cfg(feature = "json")]
928    #[cfg_attr(docsrs, doc(cfg(feature = "json")))]
929    #[must_use]
930    pub fn json(mut self) -> Self {
931        self.codec_format = CodecFormat::Json;
932        self
933    }
934
935    /// Use protobuf encoding. Shorthand for `with_codec_format(CodecFormat::Proto)`.
936    #[must_use]
937    pub fn proto(mut self) -> Self {
938        self.codec_format = CodecFormat::Proto;
939        self
940    }
941
942    /// Set the compression registry.
943    ///
944    /// Read via [`Self::compression`].
945    #[must_use]
946    pub fn with_compression(mut self, registry: CompressionRegistry) -> Self {
947        self.compression = registry;
948        self
949    }
950
951    /// Enable request compression with the specified encoding.
952    ///
953    /// Read via [`Self::request_compression`].
954    #[must_use]
955    pub fn compress_requests(mut self, encoding: impl Into<String>) -> Self {
956        self.request_compression = Some(encoding.into());
957        self
958    }
959
960    /// Set the compression policy.
961    ///
962    /// Read via [`Self::compression_policy`].
963    #[must_use]
964    pub fn with_compression_policy(mut self, policy: CompressionPolicy) -> Self {
965        self.compression_policy = policy;
966        self
967    }
968
969    /// Set a default request timeout for all calls through this config.
970    ///
971    /// Read via [`Self::default_timeout`]. Per-call
972    /// [`CallOptions::with_timeout`] overrides this.
973    #[must_use]
974    pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
975        self.default_timeout = Some(timeout);
976        self
977    }
978
979    /// Set a default maximum decompressed response message size.
980    ///
981    /// Read via [`Self::default_max_message_size`]. Per-call
982    /// [`CallOptions::with_max_message_size`] overrides this.
983    #[must_use]
984    pub fn with_default_max_message_size(mut self, size: usize) -> Self {
985        self.default_max_message_size = Some(size);
986        self
987    }
988
989    /// Add a default header applied to every request through this config.
990    ///
991    /// If the name or value cannot be converted to valid HTTP header components,
992    /// the header is silently ignored. Per-call [`CallOptions::with_header`]
993    /// entries with the same name **replace** this value (options win over
994    /// config defaults).
995    ///
996    /// Read via [`Self::default_headers`].
997    #[must_use]
998    pub fn with_default_header(
999        mut self,
1000        name: impl TryInto<http::header::HeaderName>,
1001        value: impl TryInto<http::header::HeaderValue>,
1002    ) -> Self {
1003        if let (Ok(name), Ok(value)) = (name.try_into(), value.try_into()) {
1004            self.default_headers.append(name, value);
1005        }
1006        self
1007    }
1008
1009    /// Set all default headers at once (replaces any prior default headers).
1010    ///
1011    /// Read via [`Self::default_headers`].
1012    #[must_use]
1013    pub fn with_default_headers(mut self, headers: http::HeaderMap) -> Self {
1014        self.default_headers = headers;
1015        self
1016    }
1017
1018    // ---- accessors --------------------------------------------------------
1019
1020    /// The base URI for the service (e.g., `http://localhost:8080`).
1021    ///
1022    /// Set via [`Self::new`].
1023    pub fn base_uri(&self) -> &Uri {
1024        &self.base_uri
1025    }
1026
1027    /// The wire protocol (Connect, gRPC, or gRPC-Web).
1028    ///
1029    /// Set via [`Self::with_protocol`].
1030    pub fn protocol(&self) -> Protocol {
1031        self.protocol
1032    }
1033
1034    /// The codec format (proto or json).
1035    ///
1036    /// Set via [`Self::with_codec_format`], [`Self::json`], or [`Self::proto`].
1037    pub fn codec_format(&self) -> CodecFormat {
1038        self.codec_format
1039    }
1040
1041    /// The compression registry used for request/response compression.
1042    ///
1043    /// Set via [`Self::with_compression`].
1044    pub fn compression(&self) -> &CompressionRegistry {
1045        &self.compression
1046    }
1047
1048    /// The request compression encoding (e.g., `"gzip"`), if enabled.
1049    ///
1050    /// Set via [`Self::compress_requests`].
1051    pub fn request_compression(&self) -> Option<&str> {
1052        self.request_compression.as_deref()
1053    }
1054
1055    /// The compression policy controlling when messages are compressed.
1056    ///
1057    /// Set via [`Self::with_compression_policy`].
1058    pub fn compression_policy(&self) -> CompressionPolicy {
1059        self.compression_policy
1060    }
1061
1062    /// The default request timeout for all calls through this config, if set.
1063    ///
1064    /// Set via [`Self::with_default_timeout`]. Per-call
1065    /// [`CallOptions::with_timeout`] overrides this when set.
1066    pub fn default_timeout(&self) -> Option<Duration> {
1067        self.default_timeout
1068    }
1069
1070    /// The default maximum decompressed response message size, if set.
1071    ///
1072    /// Set via [`Self::with_default_max_message_size`]. Per-call
1073    /// [`CallOptions::with_max_message_size`] overrides this when set.
1074    pub fn default_max_message_size(&self) -> Option<usize> {
1075        self.default_max_message_size
1076    }
1077
1078    /// The headers applied to every request through this config.
1079    ///
1080    /// Useful for auth tokens, user-agent, tracing context.
1081    /// Per-call [`CallOptions::with_header`] entries with the same name
1082    /// **replace** these (options win over config defaults).
1083    ///
1084    /// Set via [`Self::with_default_header`] / [`Self::with_default_headers`].
1085    pub fn default_headers(&self) -> &http::HeaderMap {
1086        &self.default_headers
1087    }
1088}
1089
1090/// Per-request options for an RPC call.
1091///
1092/// Provides per-call configuration such as additional headers and timeouts.
1093/// Use [`CallOptions::default()`] for no additional options.
1094///
1095/// `CallOptions` is `#[non_exhaustive]`: new fields may be added in minor
1096/// releases. Construct with [`CallOptions::default()`] and the `with_*`
1097/// builder methods, then read settings back through the accessor methods.
1098///
1099/// # Example
1100///
1101/// ```rust
1102/// use connectrpc::client::CallOptions;
1103/// use std::time::Duration;
1104///
1105/// let options = CallOptions::default()
1106///     .with_timeout(Duration::from_secs(5))
1107///     .with_header("x-request-id", "abc123");
1108/// assert_eq!(options.timeout(), Some(Duration::from_secs(5)));
1109/// assert_eq!(options.headers().get("x-request-id").unwrap(), "abc123");
1110/// ```
1111#[derive(Debug, Clone, Default)]
1112#[non_exhaustive]
1113pub struct CallOptions {
1114    pub(crate) headers: http::HeaderMap,
1115    pub(crate) timeout: Option<Duration>,
1116    pub(crate) max_message_size: Option<usize>,
1117    pub(crate) compress: Option<bool>,
1118}
1119
1120impl CallOptions {
1121    // ---- builders ---------------------------------------------------------
1122
1123    /// Set the request timeout.
1124    ///
1125    /// Read via [`Self::timeout`].
1126    #[must_use]
1127    pub fn with_timeout(mut self, timeout: Duration) -> Self {
1128        self.timeout = Some(timeout);
1129        self
1130    }
1131
1132    /// Add a request header.
1133    ///
1134    /// If the name or value cannot be converted to valid HTTP header components,
1135    /// the header is silently ignored. Use [`try_with_header`](Self::try_with_header)
1136    /// for fallible insertion.
1137    ///
1138    /// Read via [`Self::headers`].
1139    #[must_use]
1140    pub fn with_header(
1141        mut self,
1142        name: impl TryInto<http::header::HeaderName>,
1143        value: impl TryInto<http::header::HeaderValue>,
1144    ) -> Self {
1145        if let (Ok(name), Ok(value)) = (name.try_into(), value.try_into()) {
1146            self.headers.append(name, value);
1147        }
1148        self
1149    }
1150
1151    /// Add a request header, returning an error if the name or value is invalid.
1152    ///
1153    /// Read via [`Self::headers`].
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns [`ErrorCode::Internal`] if the name or value cannot be
1158    /// converted to a valid HTTP header component.
1159    pub fn try_with_header(
1160        mut self,
1161        name: impl TryInto<http::header::HeaderName>,
1162        value: impl TryInto<http::header::HeaderValue>,
1163    ) -> Result<Self, ConnectError> {
1164        let name = name
1165            .try_into()
1166            .map_err(|_| ConnectError::internal("invalid header name"))?;
1167        let value = value
1168            .try_into()
1169            .map_err(|_| ConnectError::internal("invalid header value"))?;
1170        self.headers.append(name, value);
1171        Ok(self)
1172    }
1173
1174    /// Add multiple request headers from an iterator.
1175    ///
1176    /// Read via [`Self::headers`].
1177    #[must_use]
1178    pub fn with_headers(
1179        mut self,
1180        headers: impl IntoIterator<Item = (http::header::HeaderName, http::header::HeaderValue)>,
1181    ) -> Self {
1182        for (name, value) in headers {
1183            self.headers.append(name, value);
1184        }
1185        self
1186    }
1187
1188    /// Set the maximum decompressed message size in bytes.
1189    ///
1190    /// Read via [`Self::max_message_size`].
1191    #[must_use]
1192    pub fn with_max_message_size(mut self, size: usize) -> Self {
1193        self.max_message_size = Some(size);
1194        self
1195    }
1196
1197    /// Override compression for this call. `Some(true)` forces compression,
1198    /// `Some(false)` disables it; not calling this defers to the configured
1199    /// [`CompressionPolicy`].
1200    ///
1201    /// Read via [`Self::compress`].
1202    #[must_use]
1203    pub fn with_compress(mut self, enabled: bool) -> Self {
1204        self.compress = Some(enabled);
1205        self
1206    }
1207
1208    // ---- accessors --------------------------------------------------------
1209
1210    /// Additional headers to include in the request.
1211    ///
1212    /// These are merged into the HTTP request after protocol headers,
1213    /// allowing override of any header for advanced use cases.
1214    ///
1215    /// Set via [`Self::with_header`] / [`Self::with_headers`].
1216    pub fn headers(&self) -> &http::HeaderMap {
1217        &self.headers
1218    }
1219
1220    /// The request timeout, sent as `connect-timeout-ms` / `grpc-timeout`.
1221    ///
1222    /// Set via [`Self::with_timeout`].
1223    pub fn timeout(&self) -> Option<Duration> {
1224        self.timeout
1225    }
1226
1227    /// The maximum decompressed message size in bytes.
1228    ///
1229    /// When set, messages exceeding this size after decompression will
1230    /// result in a `ResourceExhausted` error. Applies per-message for streaming.
1231    ///
1232    /// Set via [`Self::with_max_message_size`].
1233    pub fn max_message_size(&self) -> Option<usize> {
1234        self.max_message_size
1235    }
1236
1237    /// The per-call compression override. `Some(true)` forces compression,
1238    /// `Some(false)` disables it, `None` defers to the policy.
1239    ///
1240    /// Set via [`Self::with_compress`].
1241    pub fn compress(&self) -> Option<bool> {
1242        self.compress
1243    }
1244}
1245
1246/// Merge `options` over `config` defaults: where `options` has a value, use it;
1247/// where `options` is unset/empty, use the config default.
1248///
1249/// Headers: config defaults are applied first, then options. For any header
1250/// name present in `options`, the config's values for that name are removed
1251/// and replaced with the options' values (options override config).
1252///
1253/// `compress` has no config-level default — [`ClientConfig::compression_policy`]
1254/// already provides that control at a more appropriate granularity.
1255fn effective_options(config: &ClientConfig, options: CallOptions) -> CallOptions {
1256    CallOptions {
1257        timeout: options.timeout.or(config.default_timeout),
1258        max_message_size: options.max_message_size.or(config.default_max_message_size),
1259        compress: options.compress,
1260        headers: merge_headers(&config.default_headers, options.headers),
1261    }
1262}
1263
1264/// Merge headers: config defaults, then options override.
1265///
1266/// For each header name present in `options`, all config values for that
1267/// name are removed before appending the options' values. This ensures
1268/// per-call options fully replace config defaults for that header name
1269/// (no duplicate values leaking through).
1270fn merge_headers(config_defaults: &http::HeaderMap, options: http::HeaderMap) -> http::HeaderMap {
1271    // Fast path: no config defaults → just use options as-is (most common case).
1272    if config_defaults.is_empty() {
1273        return options;
1274    }
1275    // Fast path: no options → clone config defaults.
1276    if options.is_empty() {
1277        return config_defaults.clone();
1278    }
1279
1280    let mut merged = config_defaults.clone();
1281    // For each name in options, remove ALL config entries for that name, then
1282    // append all options' values. keys() deduplicates so remove runs once/name.
1283    for name in options.keys() {
1284        merged.remove(name);
1285    }
1286    for (name, value) in options.iter() {
1287        merged.append(name.clone(), value.clone());
1288    }
1289    merged
1290}
1291
1292const CONNECT_TIMEOUT_MAX_MILLIS: u64 = 9_999_999_999;
1293const GRPC_TIMEOUT_MAX_SECONDS: u64 = 99_999_999;
1294
1295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1296enum EncodedTimeout {
1297    Connect {
1298        millis: u64,
1299    },
1300    Grpc {
1301        value: u64,
1302        unit: char,
1303        duration: Duration,
1304    },
1305}
1306
1307impl EncodedTimeout {
1308    fn duration(self) -> Duration {
1309        match self {
1310            Self::Connect { millis } => Duration::from_millis(millis),
1311            Self::Grpc { duration, .. } => duration,
1312        }
1313    }
1314
1315    fn header_value(self) -> String {
1316        match self {
1317            Self::Connect { millis } => millis.to_string(),
1318            Self::Grpc { value, unit, .. } => format!("{value}{unit}"),
1319        }
1320    }
1321}
1322
1323fn grpc_encoded_timeout(value: u128, unit: char, duration: Duration) -> EncodedTimeout {
1324    EncodedTimeout::Grpc {
1325        value: value as u64,
1326        unit,
1327        duration,
1328    }
1329}
1330
1331/// Encode a timeout for the wire and retain the exact duration that encoding
1332/// represents so local deadline enforcement matches the transmitted budget.
1333#[allow(clippy::manual_is_multiple_of)]
1334fn encoded_timeout(timeout: Duration, protocol: Protocol) -> EncodedTimeout {
1335    match protocol {
1336        Protocol::Connect => EncodedTimeout::Connect {
1337            millis: timeout.as_millis().min(CONNECT_TIMEOUT_MAX_MILLIS as u128) as u64,
1338        },
1339        Protocol::Grpc | Protocol::GrpcWeb => {
1340            let max = GRPC_TIMEOUT_MAX_SECONDS as u128;
1341            let nanos = timeout.as_nanos();
1342            let secs = timeout.as_secs() as u128;
1343            let millis = timeout.as_millis();
1344            let micros = timeout.as_micros();
1345
1346            if nanos == 0 {
1347                grpc_encoded_timeout(0, 'n', Duration::ZERO)
1348            } else if nanos % 1_000_000_000 == 0 && secs <= max {
1349                grpc_encoded_timeout(secs, 'S', Duration::from_secs(secs as u64))
1350            } else if nanos % 1_000_000 == 0 && millis <= max {
1351                grpc_encoded_timeout(millis, 'm', Duration::from_millis(millis as u64))
1352            } else if nanos % 1_000 == 0 && micros <= max {
1353                grpc_encoded_timeout(micros, 'u', Duration::from_micros(micros as u64))
1354            } else if nanos <= max {
1355                grpc_encoded_timeout(nanos, 'n', Duration::from_nanos(nanos as u64))
1356            } else if micros <= max {
1357                grpc_encoded_timeout(micros, 'u', Duration::from_micros(micros as u64))
1358            } else if millis <= max {
1359                grpc_encoded_timeout(millis, 'm', Duration::from_millis(millis as u64))
1360            } else if secs <= max {
1361                grpc_encoded_timeout(secs, 'S', Duration::from_secs(secs as u64))
1362            } else {
1363                grpc_encoded_timeout(max, 'S', Duration::from_secs(GRPC_TIMEOUT_MAX_SECONDS))
1364            }
1365        }
1366    }
1367}
1368
1369fn client_deadline(timeout: Option<Duration>, protocol: Protocol) -> Option<std::time::Instant> {
1370    timeout
1371        .map(|t| encoded_timeout(t, protocol).duration())
1372        .and_then(|t| std::time::Instant::now().checked_add(t))
1373}
1374
1375/// Enforce a client-side deadline by wrapping a future in `timeout_at`.
1376///
1377/// gRPC deadline semantics: the deadline applies to the **entire call** from
1378/// start to completion, not per-message (grpc-java #4814, confirmed by
1379/// maintainers). When the deadline fires, all subsequent operations on the
1380/// call return `DEADLINE_EXCEEDED` — matching grpc-java's `onError` behavior
1381/// and connect-go's `ctx.Err()` check before each body read.
1382///
1383/// Returns the future's result if it completes before deadline, or
1384/// `Err(ConnectError::deadline_exceeded)` if the deadline fires first. If
1385/// `deadline` is `None`, the future runs unbounded.
1386async fn with_deadline<F, T>(
1387    deadline: Option<std::time::Instant>,
1388    fut: F,
1389) -> Result<T, ConnectError>
1390where
1391    F: Future<Output = Result<T, ConnectError>>,
1392{
1393    match deadline {
1394        None => fut.await,
1395        Some(d) => {
1396            // std::time::Instant → tokio::time::Instant (tokio's timer needs its own type).
1397            let tokio_deadline = tokio::time::Instant::from_std(d);
1398            tokio::time::timeout_at(tokio_deadline, fut)
1399                .await
1400                .map_err(|_| ConnectError::deadline_exceeded("client-side deadline exceeded"))?
1401        }
1402    }
1403}
1404
1405/// Has the call's deadline passed?
1406///
1407/// The single definition of "past the deadline" for classifying errors that
1408/// surface around a timeout. `with_deadline` decides when to *stop* waiting;
1409/// this decides how to *describe* a failure that arrived on its own, which
1410/// several paths need and which must agree between them.
1411/// Note for tests: this reads the real clock, while `with_deadline` runs on
1412/// tokio's. Under `#[tokio::test(start_paused = true)]` virtual time advances
1413/// and this does not, so a paused-time test that delivers a body *error* and
1414/// expects the timeout classification will not get it. Use real time for
1415/// those; paused time is fine when the timer is what you are exercising.
1416fn deadline_elapsed(deadline: Option<std::time::Instant>) -> bool {
1417    deadline.is_some_and(|d| std::time::Instant::now() >= d)
1418}
1419
1420/// Classify a transport error that surfaced while reading a response body.
1421///
1422/// A server enforcing the same deadline aborts the RPC independently, so its
1423/// RST_STREAM can beat the local timer: the body read fails first and the
1424/// call reports a transport fault for what is really a timeout. Which of the
1425/// two wins is down to timer coarseness and scheduler delay, so the same call
1426/// can report either code run to run.
1427///
1428/// The missing-`grpc-status` path a few hundred lines below already resolves
1429/// this by deadline rather than by arrival order; this applies the same rule
1430/// to the body-read sites, and both now share [`deadline_elapsed`] so they
1431/// cannot disagree.
1432///
1433/// `internal` is the wrong answer here because it attributes the failure to
1434/// this client when the cause was a timeout the caller asked for.
1435fn classify_body_read_error(
1436    context: &str,
1437    error: &dyn std::fmt::Display,
1438    deadline: Option<std::time::Instant>,
1439) -> ConnectError {
1440    if deadline_elapsed(deadline) {
1441        ConnectError::deadline_exceeded(format!("{context} after the deadline elapsed: {error}"))
1442    } else {
1443        ConnectError::internal(format!("{context}: {error}"))
1444    }
1445}
1446
1447/// Response from a unary RPC call.
1448///
1449/// Contains the decoded response message along with response headers and
1450/// trailing metadata.
1451#[derive(Debug)]
1452pub struct UnaryResponse<Resp> {
1453    headers: http::HeaderMap,
1454    body: Resp,
1455    trailers: http::HeaderMap,
1456}
1457
1458impl<Resp> UnaryResponse<Resp> {
1459    /// Returns the response headers.
1460    #[must_use]
1461    pub fn headers(&self) -> &http::HeaderMap {
1462        &self.headers
1463    }
1464
1465    /// Consume the response, returning just the body.
1466    ///
1467    /// For generated clients this is an [`OwnedView`] — zero-copy, move
1468    /// semantics, suitable for keeping the decoded body around without
1469    /// copying. Field access on it goes through
1470    /// [`reborrow()`](OwnedView::reborrow); for inline reads prefer
1471    /// [`view()`](Self::view), and for an owned struct use
1472    /// [`into_owned()`](Self::into_owned).
1473    #[must_use]
1474    pub fn into_view(self) -> Resp {
1475        self.body
1476    }
1477
1478    /// Returns the trailing metadata.
1479    #[must_use]
1480    pub fn trailers(&self) -> &http::HeaderMap {
1481        &self.trailers
1482    }
1483
1484    /// Consume the response, returning `(headers, body, trailers)`.
1485    #[must_use]
1486    pub fn into_parts(self) -> (http::HeaderMap, Resp, http::HeaderMap) {
1487        (self.headers, self.body, self.trailers)
1488    }
1489}
1490
1491/// Convenience for the common generated-client case where the body is an
1492/// [`OwnedView`]. Generated unary client methods always return this shape.
1493impl<V> UnaryResponse<OwnedView<V>>
1494where
1495    V: MessageView<'static>,
1496{
1497    /// Consume the response and return the fully-owned message, discarding
1498    /// headers and trailers.
1499    ///
1500    /// This allocates and copies all borrowed fields (strings, bytes, nested
1501    /// messages). Prefer zero-copy view access via
1502    /// [`view()`](UnaryResponse::view) unless you need to pass the owned
1503    /// struct to code that expects it, or store it in a collection.
1504    ///
1505    /// ```rust,ignore
1506    /// let owned: FooResponse = client.foo(req).await?.into_owned();
1507    /// ```
1508    ///
1509    /// Infallible — see [`into_owned_parts()`](Self::into_owned_parts) for
1510    /// the argument.
1511    #[must_use]
1512    pub fn into_owned(self) -> V::Owned {
1513        self.into_owned_parts().1
1514    }
1515
1516    /// Consume the response, returning `(headers, owned message, trailers)`.
1517    ///
1518    /// The metadata-preserving sibling of [`into_owned()`](Self::into_owned),
1519    /// for callers that also need the response's header and trailer
1520    /// metadata.
1521    ///
1522    /// Infallible: [`OwnedView::to_owned_message`] cannot fail, because an
1523    /// `OwnedView` can only come from buffa's wire decoder and conversion
1524    /// replays under the budget the decode already charged.
1525    #[must_use]
1526    pub fn into_owned_parts(self) -> (http::HeaderMap, V::Owned, http::HeaderMap) {
1527        (self.headers, self.body.to_owned_message(), self.trailers)
1528    }
1529}
1530
1531/// Zero-copy read access for [`OwnedView`] bodies whose view supports
1532/// reborrowing (every buffa-generated view does).
1533impl<V> UnaryResponse<OwnedView<V>>
1534where
1535    V: ViewReborrow,
1536{
1537    /// Borrow the response message view, tied to `&self`.
1538    ///
1539    /// Field access on the returned view is zero-copy:
1540    ///
1541    /// ```rust,ignore
1542    /// let resp = client.foo(req).await?;
1543    /// assert_eq!(resp.view().name, "expected");  // &str, no allocation
1544    /// ```
1545    ///
1546    /// See also [`into_view()`](UnaryResponse::into_view) to keep the decoded
1547    /// body and [`into_owned()`](UnaryResponse::into_owned) for an owned
1548    /// struct.
1549    #[must_use]
1550    pub fn view(&self) -> &V::Reborrowed<'_> {
1551        self.body.reborrow()
1552    }
1553}
1554
1555/// Decode a response message as an `OwnedView` from bytes.
1556///
1557/// For proto-encoded responses, this is a true zero-copy decode — the view borrows
1558/// directly from the response bytes. For JSON-encoded responses, the data is first
1559/// deserialized to an owned message, then re-encoded to proto bytes and decoded as
1560/// a view. This JSON round-trip adds overhead relative to owned-type decoding, but
1561/// is negligible compared to JSON parsing itself.
1562fn decode_response_view<RespView>(
1563    data: Bytes,
1564    format: CodecFormat,
1565) -> Result<OwnedView<RespView>, ConnectError>
1566where
1567    RespView: MessageView<'static> + Send,
1568    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
1569{
1570    match format {
1571        CodecFormat::Proto => OwnedView::<RespView>::decode(data)
1572            .map_err(|e| ConnectError::internal(format!("failed to decode response: {e}"))),
1573        #[cfg(feature = "json")]
1574        CodecFormat::Json => {
1575            let owned: RespView::Owned = serde_json::from_slice(&data).map_err(|e| {
1576                ConnectError::internal(format!("failed to decode JSON response: {e}"))
1577            })?;
1578            OwnedView::<RespView>::from_owned(&owned)
1579                .map_err(|e| ConnectError::internal(format!("failed to re-encode for view: {e}")))
1580        }
1581        #[cfg(not(feature = "json"))]
1582        CodecFormat::Json => Err(ConnectError::unimplemented(
1583            crate::codec::JSON_FEATURE_DISABLED,
1584        )),
1585    }
1586}
1587
1588/// Make a unary RPC call.
1589///
1590/// This is the core function used by generated clients to make RPC calls.
1591/// It handles encoding, compression, and protocol details.
1592pub async fn call_unary<T, Req, RespView>(
1593    transport: &T,
1594    config: &ClientConfig,
1595    service: &str,
1596    method: &str,
1597    request: Req,
1598    options: CallOptions,
1599) -> Result<UnaryResponse<OwnedView<RespView>>, ConnectError>
1600where
1601    T: ClientTransport,
1602    <T::ResponseBody as Body>::Error: std::fmt::Display,
1603    Req: buffa::Message + crate::codec::JsonSerialize,
1604    RespView: MessageView<'static> + Send,
1605    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
1606{
1607    let options = effective_options(config, options);
1608
1609    // Build the full URI from base_uri and service/method path
1610    let base_str = config.base_uri.to_string();
1611    let base_str = base_str.trim_end_matches('/');
1612    let full_uri = format!("{base_str}/{service}/{method}");
1613    let uri: Uri = full_uri
1614        .parse()
1615        .map_err(|e| ConnectError::internal(format!("invalid URI: {e}")))?;
1616
1617    // Encode the request body
1618    let body = match config.codec_format {
1619        CodecFormat::Proto => request.encode_to_bytes(),
1620        CodecFormat::Json => encode_json(&request)?,
1621    };
1622
1623    // Apply compression and framing based on protocol.
1624    // Connect unary: compression at HTTP level (Content-Encoding) — the
1625    //   header must only be set when the body is ACTUALLY compressed (bug
1626    //   if the compression policy skips small messages but we still send
1627    //   Content-Encoding).
1628    // gRPC/gRPC-Web: compression at envelope level — the `grpc-encoding`
1629    //   header declares the algorithm used WHEN the per-message envelope
1630    //   flag is set, so it's fine to send even if the policy decides not
1631    //   to compress a particular message.
1632    let (body, applied_content_encoding) = match config.protocol {
1633        Protocol::Grpc | Protocol::GrpcWeb => {
1634            let compression_for_encoder = config.request_compression.as_ref().map(|enc| {
1635                (
1636                    std::sync::Arc::new(config.compression.clone()),
1637                    enc.as_str(),
1638                )
1639            });
1640            let mut encoder = crate::envelope::EnvelopeEncoder::new(
1641                compression_for_encoder,
1642                config.compression_policy.with_override(options.compress),
1643            );
1644            let mut buf = bytes::BytesMut::new();
1645            tokio_util::codec::Encoder::encode(&mut encoder, body, &mut buf)
1646                .map_err(|e| ConnectError::internal(format!("envelope encode failed: {e}")))?;
1647            (buf.freeze(), None)
1648        }
1649        Protocol::Connect => {
1650            if let Some(ref encoding) = config.request_compression {
1651                let effective_policy = config.compression_policy.with_override(options.compress);
1652                if effective_policy.should_compress(body.len()) {
1653                    let compressed = config.compression.compress(encoding, &body)?;
1654                    (compressed, Some(encoding.as_str()))
1655                } else {
1656                    (body, None)
1657                }
1658            } else {
1659                (body, None)
1660            }
1661        }
1662    };
1663
1664    // Compute deadline BEFORE sending the request, matching how Go's
1665    // ctx.Deadline() works. The server enforces the same deadline via
1666    // grpc-timeout, so by the time we check, the elapsed time since
1667    // request start is what matters.
1668    let deadline = client_deadline(options.timeout, config.protocol);
1669
1670    // Build the HTTP request with protocol-aware headers
1671    let mut builder = Request::builder().method(http::Method::POST).uri(uri);
1672    builder = add_unary_request_headers(builder, config, options.timeout, applied_content_encoding);
1673
1674    // Merge user-provided headers (last, so they can override anything)
1675    let headers = builder.headers_mut().unwrap();
1676    for (name, value) in &options.headers {
1677        headers.append(name.clone(), value.clone());
1678    }
1679
1680    let http_request = builder
1681        .body(full_body(body))
1682        .map_err(|e| ConnectError::internal(format!("failed to build request: {e}")))?;
1683
1684    // Enforce client-side deadline on send + parse. The server also
1685    // enforces via grpc-timeout/connect-timeout-ms header, but a hung or
1686    // misbehaving server shouldn't block the client indefinitely.
1687    with_deadline(deadline, async {
1688        let response = transport
1689            .send(http_request)
1690            .await
1691            .map_err(|e| map_transport_send_error(e, "request failed"))?;
1692
1693        match config.protocol {
1694            Protocol::Connect => {
1695                parse_connect_unary_response(response, config, &options, deadline).await
1696            }
1697            Protocol::Grpc | Protocol::GrpcWeb => {
1698                parse_grpc_unary_response(response, config, &options, deadline).await
1699            }
1700        }
1701    })
1702    .await
1703}
1704
1705/// Make an idempotent unary RPC call via HTTP GET (Connect protocol only).
1706///
1707/// The request is encoded into URL query parameters per the Connect spec:
1708/// `?connect=v1[&base64=1][&compression=<enc>]&encoding=<codec>&message=<payload>`.
1709///
1710/// For proto (or any binary codec), the message is URL-safe base64-encoded
1711/// without padding and `base64=1` is set. For JSON, the message is
1712/// percent-encoded directly (no base64). Compression adds `compression=`
1713/// and always uses base64 (compressed bytes are binary).
1714///
1715/// GET requests are cacheable by browsers/proxies/CDNs — useful for
1716/// side-effect-free queries. Only the Connect protocol supports this;
1717/// gRPC/gRPC-Web are POST-only.
1718///
1719/// # Deterministic encoding
1720///
1721/// For effective caching, the encoded message should be deterministic (same
1722/// domain object → same bytes). buffa's proto encoder is deterministic
1723/// (fields walked in field-number order). serde_json is NOT guaranteed
1724/// deterministic — if you need JSON + caching, consider a custom serializer.
1725///
1726/// # Errors
1727///
1728/// Returns `invalid_argument` if `config.protocol` is not `Connect`.
1729pub async fn call_unary_get<T, Req, RespView>(
1730    transport: &T,
1731    config: &ClientConfig,
1732    service: &str,
1733    method: &str,
1734    request: Req,
1735    options: CallOptions,
1736) -> Result<UnaryResponse<OwnedView<RespView>>, ConnectError>
1737where
1738    T: ClientTransport,
1739    <T::ResponseBody as Body>::Error: std::fmt::Display,
1740    Req: buffa::Message + crate::codec::JsonSerialize,
1741    RespView: MessageView<'static> + Send,
1742    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
1743{
1744    // Connect GET is a Connect-protocol-only feature.
1745    if !matches!(config.protocol, Protocol::Connect) {
1746        return Err(ConnectError::invalid_argument(
1747            "call_unary_get requires Protocol::Connect (gRPC/gRPC-Web are POST-only)",
1748        ));
1749    }
1750
1751    let options = effective_options(config, options);
1752
1753    // Build the base URI (no query yet)
1754    let base_str = config.base_uri.to_string();
1755    let base_str = base_str.trim_end_matches('/');
1756
1757    // Encode the request body
1758    let body = match config.codec_format {
1759        CodecFormat::Proto => request.encode_to_bytes(),
1760        CodecFormat::Json => encode_json(&request)?,
1761    };
1762
1763    // Apply compression if configured (compression makes base64 mandatory).
1764    let (payload, compressed_with) = if let Some(ref encoding) = config.request_compression {
1765        let effective_policy = config.compression_policy.with_override(options.compress);
1766        if effective_policy.should_compress(body.len()) {
1767            let compressed = config.compression.compress(encoding, &body)?;
1768            (compressed, Some(encoding.as_str()))
1769        } else {
1770            (body, None)
1771        }
1772    } else {
1773        (body, None)
1774    };
1775
1776    // Build the query string. Per spec:
1777    // - proto/binary OR compressed → URL-safe base64 (no padding) + base64=1
1778    // - uncompressed JSON → percent-encode directly (it's UTF-8 text)
1779    let is_binary_codec = matches!(config.codec_format, CodecFormat::Proto);
1780    let use_base64 = is_binary_codec || compressed_with.is_some();
1781
1782    let encoded_message = if use_base64 {
1783        // RFC 4648 §5 URL-safe base64, no padding (matching connect-go's
1784        // base64.RawURLEncoding.EncodeToString).
1785        use base64::Engine;
1786        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&payload)
1787    } else {
1788        // Percent-encode the JSON bytes directly. It's valid UTF-8 by
1789        // construction (serde_json::to_vec produces UTF-8). Use a
1790        // conservative encode set — query component.
1791        percent_encoding::percent_encode(&payload, percent_encoding::NON_ALPHANUMERIC).to_string()
1792    };
1793
1794    let encoding_name = match config.codec_format {
1795        CodecFormat::Proto => "proto",
1796        CodecFormat::Json => "json",
1797    };
1798
1799    let query =
1800        build_connect_get_query(use_base64, compressed_with, encoding_name, &encoded_message);
1801
1802    let full_uri = format!("{base_str}/{service}/{method}?{query}");
1803    let uri: Uri = full_uri
1804        .parse()
1805        .map_err(|e| ConnectError::internal(format!("invalid GET URI: {e}")))?;
1806
1807    let deadline = client_deadline(options.timeout, Protocol::Connect);
1808
1809    // GET request: no body, no Content-Type, no Content-Encoding.
1810    // Timeout still goes in the header (spec: "timeouts, if specified,
1811    // remain specified using HTTP headers rather than query parameters").
1812    let mut builder = Request::builder().method(http::Method::GET).uri(uri);
1813    if let Some(timeout) = options.timeout {
1814        builder = builder.header(
1815            crate::codec::header::TIMEOUT_MS,
1816            format_timeout(timeout, Protocol::Connect),
1817        );
1818    }
1819    // Accept-Encoding so the server can compress the response.
1820    let accept = config.compression.accept_encoding_header();
1821    if !accept.is_empty() {
1822        builder = builder.header(http::header::ACCEPT_ENCODING, accept);
1823    }
1824
1825    // Merge user-provided headers
1826    let headers = builder.headers_mut().unwrap();
1827    for (name, value) in &options.headers {
1828        headers.append(name.clone(), value.clone());
1829    }
1830
1831    let http_request = builder
1832        .body(full_body(Bytes::new()))
1833        .map_err(|e| ConnectError::internal(format!("failed to build GET request: {e}")))?;
1834
1835    with_deadline(deadline, async {
1836        let response = transport
1837            .send(http_request)
1838            .await
1839            .map_err(|e| map_transport_send_error(e, "GET request failed"))?;
1840
1841        // Response format is identical to POST unary Connect.
1842        parse_connect_unary_response(response, config, &options, deadline).await
1843    })
1844    .await
1845}
1846
1847/// Assemble the Connect Unary-Get query string.
1848///
1849/// Servers must accept any parameter order; the spec's Query-Get ABNF rule
1850/// fixes the order so the variable-length `message` comes last and the
1851/// prefix is stable for shared HTTP caches: `connect`, `base64`,
1852/// `compression`, `encoding`, `message` ("Clients should order parameters as
1853/// shown in the Query-Get rule above to maximize hit rates on shared
1854/// caches" — <https://connectrpc.com/docs/protocol#unary-get-request>).
1855/// connect-go and the conformance reference-server order check both follow
1856/// this rule.
1857fn build_connect_get_query(
1858    use_base64: bool,
1859    compression: Option<&str>,
1860    encoding: &str,
1861    encoded_message: &str,
1862) -> String {
1863    let mut query = String::with_capacity(
1864        "connect=v1&encoding=&message=".len()
1865            + if use_base64 { "&base64=1".len() } else { 0 }
1866            + compression.map_or(0, |c| "&compression=".len() + c.len())
1867            + encoding.len()
1868            + encoded_message.len(),
1869    );
1870    query.push_str("connect=v1");
1871    if use_base64 {
1872        query.push_str("&base64=1");
1873    }
1874    if let Some(enc) = compression {
1875        query.push_str("&compression=");
1876        query.push_str(enc);
1877    }
1878    query.push_str("&encoding=");
1879    query.push_str(encoding);
1880    query.push_str("&message=");
1881    query.push_str(encoded_message);
1882    query
1883}
1884
1885/// Stamp a terminal error with the metadata its response delivered.
1886///
1887/// The response-parsing paths build errors several layers from the
1888/// response — a bounded body read, a decompression provider, a codec — and
1889/// none of those layers has the headers. Rather than teach each of them,
1890/// the parse functions map their errors through this on the way out.
1891fn with_response_metadata<'a>(
1892    headers: &'a http::HeaderMap,
1893    trailers: &'a http::HeaderMap,
1894) -> impl Fn(ConnectError) -> ConnectError + 'a {
1895    move |mut err| {
1896        err.set_response_headers(headers.clone());
1897        if !trailers.is_empty() {
1898            err.set_trailers(trailers.clone());
1899        }
1900        err
1901    }
1902}
1903
1904/// Remap decompression error codes for payloads received from the server.
1905///
1906/// The compression providers classify malformed input as `invalid_argument`
1907/// and unknown encodings as `unimplemented`, which is the right attribution
1908/// when a server decompresses a request. On the client the payload is a
1909/// response, so the fault lies with the server (or an intermediary), not
1910/// with the caller:
1911///
1912/// - `unimplemented` (unknown encoding) becomes `internal`: the server
1913///   chose an encoding the client never advertised.
1914/// - `invalid_argument` (malformed payload) becomes `data_loss`: the bytes
1915///   arrived but were corrupt. This deliberately diverges from connect-go,
1916///   which reports `invalid_argument` in both directions; `data_loss`
1917///   describes the failure without implying the request was at fault.
1918fn map_response_decompression_error(mut e: ConnectError) -> ConnectError {
1919    match e.code {
1920        ErrorCode::Unimplemented => e.code = ErrorCode::Internal,
1921        ErrorCode::InvalidArgument => e.code = ErrorCode::DataLoss,
1922        _ => {}
1923    }
1924    e
1925}
1926
1927/// Parse a Connect protocol unary response.
1928async fn parse_connect_unary_response<B, RespView>(
1929    response: Response<B>,
1930    config: &ClientConfig,
1931    options: &CallOptions,
1932    deadline: Option<std::time::Instant>,
1933) -> Result<UnaryResponse<OwnedView<RespView>>, ConnectError>
1934where
1935    B: Body<Data = Bytes> + Send,
1936    B::Error: std::fmt::Display,
1937    RespView: MessageView<'static> + Send,
1938    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
1939{
1940    let status = response.status();
1941    if !status.is_success() {
1942        let response_headers = response.headers().clone();
1943        let mut trailers = http::HeaderMap::new();
1944        let mut headers = http::HeaderMap::new();
1945        for (name, value) in &response_headers {
1946            if let Some(trailer_name) = name.as_str().strip_prefix("trailer-") {
1947                if let Ok(name) = http::header::HeaderName::from_bytes(trailer_name.as_bytes()) {
1948                    trailers.append(name, value.clone());
1949                }
1950            } else {
1951                headers.append(name.clone(), value.clone());
1952            }
1953        }
1954
1955        let error_encoding = response_headers
1956            .get(http::header::CONTENT_ENCODING)
1957            .and_then(|v| v.to_str().ok())
1958            .map(|s| s.to_owned());
1959
1960        let max_err_body_size = options
1961            .max_message_size
1962            .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE);
1963
1964        let body = collect_body_bounded(response.into_body(), max_err_body_size, deadline)
1965            .await
1966            .map_err(|mut e| {
1967                e.set_response_headers(headers.clone());
1968                e.set_trailers(trailers.clone());
1969                e
1970            })?;
1971
1972        // Decompress if the server set Content-Encoding. If decompression
1973        // fails (unknown encoding, corrupt data), the body is unusable — skip
1974        // JSON parsing and fall through to the HTTP-status-based error below.
1975        let body = match error_encoding {
1976            Some(encoding) => {
1977                match config
1978                    .compression
1979                    .decompress_with_limit(&encoding, body, max_err_body_size)
1980                {
1981                    Ok(decompressed) => decompressed,
1982                    Err(e) => {
1983                        tracing::debug!(
1984                            "failed to decompress Connect error response ({encoding}): {e}"
1985                        );
1986                        let mut err = ConnectError::new(
1987                            http_status_to_error_code(status),
1988                            format!("HTTP error {}", status.as_u16()),
1989                        );
1990                        err.set_response_headers(headers);
1991                        err.set_trailers(trailers);
1992                        return Err(err);
1993                    }
1994                }
1995            }
1996            None => body,
1997        };
1998
1999        if let Ok(error) = serde_json::from_slice::<ConnectErrorResponse>(&body) {
2000            let code = error
2001                .code
2002                .as_deref()
2003                .and_then(|s| s.parse::<ErrorCode>().ok())
2004                .unwrap_or_else(|| http_status_to_error_code(status));
2005            let mut err = ConnectError::new(code, error.message.unwrap_or_default());
2006            err.details = error.details;
2007            err.set_response_headers(headers);
2008            err.set_trailers(trailers);
2009            return Err(err);
2010        }
2011
2012        let code = http_status_to_error_code(status);
2013        let mut err = ConnectError::new(
2014            code,
2015            format!(
2016                "HTTP error {}: {}",
2017                status.as_u16(),
2018                String::from_utf8_lossy(&body)
2019            ),
2020        );
2021        err.set_response_headers(headers);
2022        err.set_trailers(trailers);
2023        return Err(err);
2024    }
2025
2026    let mut resp_headers = http::HeaderMap::new();
2027    let mut resp_trailers = http::HeaderMap::new();
2028    for (name, value) in response.headers() {
2029        if let Some(trailer_name) = name.as_str().strip_prefix("trailer-") {
2030            if let Ok(name) = http::header::HeaderName::from_bytes(trailer_name.as_bytes()) {
2031                resp_trailers.append(name, value.clone());
2032            }
2033        } else {
2034            resp_headers.append(name.clone(), value.clone());
2035        }
2036    }
2037
2038    let expected_content_type = config.codec_format.content_type();
2039    if let Some(resp_content_type) = response.headers().get(http::header::CONTENT_TYPE) {
2040        let ct = resp_content_type.to_str().unwrap_or("");
2041        if !ct.starts_with(expected_content_type) {
2042            let code = if ct.starts_with(content_type::PROTO) || ct.starts_with(content_type::JSON)
2043            {
2044                ErrorCode::Internal
2045            } else {
2046                ErrorCode::Unknown
2047            };
2048            let mut err = ConnectError::new(code, format!("unexpected content-type: {ct}"));
2049            err.set_response_headers(resp_headers);
2050            err.set_trailers(resp_trailers);
2051            return Err(err);
2052        }
2053    }
2054
2055    let response_encoding = response
2056        .headers()
2057        .get(http::header::CONTENT_ENCODING)
2058        .and_then(|v| v.to_str().ok())
2059        .map(|s| s.to_owned());
2060
2061    let max_message_size = options
2062        .max_message_size
2063        .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE);
2064
2065    // Everything below fails after a complete response was received, so
2066    // every error out of it carries that response's metadata.
2067    let attach = with_response_metadata(&resp_headers, &resp_trailers);
2068
2069    let body = collect_body_bounded(response.into_body(), max_message_size, deadline)
2070        .await
2071        .map_err(&attach)?;
2072
2073    let body = if let Some(encoding) = response_encoding {
2074        config
2075            .compression
2076            .decompress_with_limit(&encoding, body, max_message_size)
2077            .map_err(map_response_decompression_error)
2078            .map_err(&attach)?
2079    } else {
2080        body
2081    };
2082
2083    if body.len() > max_message_size {
2084        return Err(attach(ConnectError::new(
2085            ErrorCode::ResourceExhausted,
2086            format!(
2087                "message size {} exceeds limit {}",
2088                body.len(),
2089                max_message_size
2090            ),
2091        )));
2092    }
2093
2094    let message = decode_response_view::<RespView>(body, config.codec_format).map_err(&attach)?;
2095    drop(attach);
2096
2097    Ok(UnaryResponse {
2098        headers: resp_headers,
2099        body: message,
2100        trailers: resp_trailers,
2101    })
2102}
2103
2104/// Parse a gRPC/gRPC-Web unary response.
2105///
2106/// For gRPC: body is a single envelope, trailers via HTTP/2 trailers.
2107/// For gRPC-Web: body contains envelope + 0x80 trailer frame.
2108async fn parse_grpc_unary_response<B, RespView>(
2109    response: Response<B>,
2110    config: &ClientConfig,
2111    options: &CallOptions,
2112    deadline: Option<std::time::Instant>,
2113) -> Result<UnaryResponse<OwnedView<RespView>>, ConnectError>
2114where
2115    B: Body<Data = Bytes> + Send,
2116    B::Error: std::fmt::Display,
2117    RespView: MessageView<'static> + Send,
2118    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
2119{
2120    let status = response.status();
2121    let resp_headers = response.headers().clone();
2122
2123    // Non-200 HTTP status (connection-level error)
2124    if !status.is_success() {
2125        let code = http_status_to_error_code(status);
2126        let mut err = ConnectError::new(code, format!("HTTP error {}", status.as_u16()));
2127        err.set_response_headers(resp_headers);
2128        return Err(err);
2129    }
2130
2131    validate_grpc_response_content_type(&resp_headers, config)?;
2132
2133    // Check for unsupported compression before reading the body
2134    let response_encoding = resp_headers
2135        .get("grpc-encoding")
2136        .and_then(|v| v.to_str().ok())
2137        .map(|s| s.to_owned());
2138
2139    if let Some(ref enc) = response_encoding
2140        && enc != "identity"
2141        && !config.compression.supports(enc)
2142    {
2143        let mut err = ConnectError::internal(format!("unsupported response compression: {enc}"));
2144        err.set_response_headers(resp_headers);
2145        return Err(err);
2146    }
2147
2148    // Read response body frame-by-frame to capture both data and HTTP/2 trailers.
2149    // Using collect().to_bytes() would lose the trailers.
2150    let mut body = std::pin::pin!(response.into_body());
2151    let mut buf = BytesMut::new();
2152    let mut grpc_trailers = http::HeaderMap::new();
2153    let mut has_body_data = false;
2154    // For unary/client-stream, expect at most one envelope + trailer.
2155    // Cap buffer to prevent a malicious server from forcing unbounded allocation.
2156    let max_buf_size = options
2157        .max_message_size
2158        .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE)
2159        .saturating_add(crate::envelope::HEADER_SIZE)
2160        .saturating_add(RESPONSE_BUFFER_TRAILER_SLACK);
2161
2162    loop {
2163        match std::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await {
2164            Some(Ok(frame)) => {
2165                if frame.is_data() {
2166                    if let Ok(data) = frame.into_data() {
2167                        if !data.is_empty() {
2168                            has_body_data = true;
2169                        }
2170                        let remaining = max_buf_size.saturating_sub(buf.len());
2171                        let append_len = data.len().min(remaining);
2172                        buf.extend_from_slice(&data[..append_len]);
2173                        if matches!(config.protocol, Protocol::GrpcWeb)
2174                            && let Some(trailer_end) = grpc_web_trailer_frame_end(&buf)
2175                        {
2176                            buf.truncate(trailer_end);
2177                            break;
2178                        }
2179                        if append_len < data.len() {
2180                            return Err(ConnectError::resource_exhausted(format!(
2181                                "response body size exceeds limit {max_buf_size}"
2182                            )));
2183                        }
2184                    }
2185                } else if frame.is_trailers()
2186                    && let Ok(trailers) = frame.into_trailers()
2187                {
2188                    grpc_trailers = trailers;
2189                }
2190            }
2191            Some(Err(e)) => {
2192                return Err(classify_body_read_error(
2193                    "failed to read response body",
2194                    &e,
2195                    deadline,
2196                ));
2197            }
2198            None => break,
2199        }
2200    }
2201
2202    // Determine the authoritative source for grpc-status:
2203    // - If HTTP/2 trailers have grpc-status, use those (highest priority)
2204    // - If body has a gRPC-Web trailer frame, use that
2205    // - Only if no body data AND no HTTP/2 trailers, fall back to initial headers
2206    //   (trailers-only response)
2207    let mut message_data: Option<Bytes> = None;
2208    let mut message_count = 0u32;
2209
2210    while !buf.is_empty() {
2211        // Check for gRPC-Web trailer frame (flag 0x80)
2212        if buf[0] & 0x80 != 0 {
2213            let decompression = response_encoding
2214                .as_deref()
2215                .map(|enc| (&config.compression, enc));
2216            if let Some(trailers) =
2217                parse_grpc_web_trailer_frame_with_compression(&buf, decompression)
2218            {
2219                grpc_trailers = trailers;
2220            }
2221            break;
2222        }
2223
2224        let grpc_max_msg = options
2225            .max_message_size
2226            .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE);
2227
2228        let envelope = match Envelope::decode_with_limit(&mut buf, grpc_max_msg) {
2229            Ok(Some(env)) => env,
2230            Ok(None) => break,
2231            Err(e) => {
2232                return Err(ConnectError::internal(format!(
2233                    "envelope decode failed: {e}"
2234                )));
2235            }
2236        };
2237
2238        if message_count > 0 {
2239            let mut err = ConnectError::unimplemented(
2240                "received multiple response messages where exactly one was expected",
2241            );
2242            err.set_response_headers(resp_headers);
2243            return Err(err);
2244        }
2245
2246        let data = if envelope.is_compressed() {
2247            let enc = response_encoding.as_deref().ok_or_else(|| {
2248                ConnectError::internal("received compressed message without grpc-encoding header")
2249            })?;
2250            if enc == "identity" {
2251                return Err(ConnectError::internal(
2252                    "received compressed message with identity encoding",
2253                ));
2254            }
2255            config
2256                .compression
2257                .decompress_with_limit(enc, envelope.data, grpc_max_msg)
2258                .map_err(map_response_decompression_error)?
2259        } else {
2260            envelope.data
2261        };
2262
2263        message_count += 1;
2264        message_data = Some(data);
2265    }
2266
2267    // Check for errors in trailers (HTTP/2 trailers or gRPC-Web trailer frame).
2268    // If we have trailers from HTTP/2 or gRPC-Web, those take precedence.
2269    // Only fall back to initial headers if no body data was received (trailers-only).
2270    let effective_trailers = if !grpc_trailers.is_empty() {
2271        &grpc_trailers
2272    } else if !has_body_data {
2273        // Trailers-only response: initial headers contain the status
2274        &resp_headers
2275    } else {
2276        &grpc_trailers // empty — no trailers found
2277    };
2278
2279    if let Some(mut err) = parse_grpc_error_from_trailers(effective_trailers) {
2280        err.set_response_headers(resp_headers);
2281        return Err(err);
2282    }
2283
2284    // For missing grpc-status, synthesize an error.
2285    // If a deadline was set and has passed, map to DEADLINE_EXCEEDED per the gRPC
2286    // spec: RST_STREAM CANCEL is upgraded to DeadlineExceeded when the deadline
2287    // has elapsed (matching grpc-go and connect-go behavior).
2288    if effective_trailers.get("grpc-status").is_none() {
2289        let mut err = if deadline_elapsed(deadline) {
2290            // Terser than the body-read path's message on purpose: there is
2291            // no transport error here to carry, only a missing trailer.
2292            ConnectError::deadline_exceeded("request timeout")
2293        } else {
2294            ConnectError::internal("gRPC response missing grpc-status trailer")
2295        };
2296        err.set_response_headers(resp_headers);
2297        return Err(err);
2298    }
2299
2300    let data = match message_data {
2301        Some(data) => data,
2302        None => {
2303            // No message data — this is an error for unary/client-stream RPCs.
2304            let mut err = ConnectError::unimplemented("gRPC response contained no message data");
2305            err.set_response_headers(resp_headers);
2306            return Err(err);
2307        }
2308    };
2309
2310    if let Some(max_size) = options.max_message_size
2311        && data.len() > max_size
2312    {
2313        return Err(ConnectError::new(
2314            ErrorCode::ResourceExhausted,
2315            format!("message size {} exceeds limit {}", data.len(), max_size),
2316        ));
2317    }
2318
2319    let message = decode_response_view::<RespView>(data, config.codec_format)?;
2320
2321    Ok(UnaryResponse {
2322        headers: resp_headers,
2323        body: message,
2324        trailers: grpc_trailers,
2325    })
2326}
2327
2328/// Validate the `content-type` of a gRPC / gRPC-Web response against the
2329/// client's configured protocol and codec, mirroring connect-go's
2330/// `grpcValidateResponseContentType`.
2331///
2332/// Parameters (`; charset=...`) are stripped before comparison. The bare
2333/// family types `application/grpc` / `application/grpc-web` are accepted for
2334/// any codec, because the bare type means "proto by default" and proxies that
2335/// synthesize trailers-only error responses (such as Envoy local replies)
2336/// send it regardless of the request's subtype. A missing `content-type`
2337/// header is also accepted, preserving this client's previous leniency. A
2338/// same-family subtype that doesn't match the configured codec is rejected as
2339/// `internal` (a broken server or intermediary); anything else is `unknown`
2340/// (not a gRPC response at all), matching connect-go's classification.
2341fn validate_grpc_response_content_type(
2342    resp_headers: &http::HeaderMap,
2343    config: &ClientConfig,
2344) -> Result<(), ConnectError> {
2345    debug_assert!(
2346        matches!(config.protocol, Protocol::Grpc | Protocol::GrpcWeb),
2347        "gRPC response content-type validation is only for gRPC/gRPC-Web"
2348    );
2349
2350    let Some(resp_content_type) = resp_headers.get(http::header::CONTENT_TYPE) else {
2351        return Ok(());
2352    };
2353
2354    let ct = resp_content_type.to_str().unwrap_or("");
2355    let ct_normalized = ct
2356        .split_once(';')
2357        .map_or(ct, |(media_type, _params)| media_type)
2358        .trim();
2359    let expected = config
2360        .protocol
2361        .response_content_type(config.codec_format, false);
2362    let (bare, family_prefix) = match config.protocol {
2363        Protocol::Grpc => ("application/grpc", "application/grpc+"),
2364        Protocol::GrpcWeb => ("application/grpc-web", "application/grpc-web+"),
2365        // Unreachable per the debug_assert above; treat as valid rather than
2366        // misclassify a Connect response in release builds.
2367        Protocol::Connect => return Ok(()),
2368    };
2369
2370    if ct_normalized == expected || ct_normalized == bare {
2371        return Ok(());
2372    }
2373
2374    let code = if ct_normalized.starts_with(family_prefix) {
2375        ErrorCode::Internal
2376    } else {
2377        ErrorCode::Unknown
2378    };
2379    let mut err = ConnectError::new(
2380        code,
2381        format!("unexpected content-type: {ct} (expected {expected})"),
2382    );
2383    err.set_response_headers(resp_headers.clone());
2384    Err(err)
2385}
2386
2387/// Terminal record for a client stream: why it ended and what trailing
2388/// metadata arrived. Written exactly once (by `message()`); read by the
2389/// sticky replay, [`ServerStream::error()`], and
2390/// [`ServerStream::trailers()`] — one fact, three readers, so they cannot
2391/// disagree.
2392#[derive(Debug)]
2393struct StreamEnd {
2394    /// `Ok(())` is a clean end (the RPC succeeded).
2395    outcome: Result<(), ConnectError>,
2396    trailers: Option<http::HeaderMap>,
2397}
2398
2399impl StreamEnd {
2400    /// Give a failed end the metadata the response already delivered: the
2401    /// response headers, which a `ServerStream` cannot exist without, and
2402    /// the trailing metadata whenever the end carried any. Applied at the
2403    /// one site that writes the record rather than at each site that
2404    /// builds one, so no construction path can forget it.
2405    ///
2406    /// `self.trailers` is the wire map, which on the gRPC shapes still
2407    /// holds the status-bearing keys; the error gets the filtered view, so
2408    /// this recomputes what `parse_grpc_error_from_trailers` already
2409    /// derived rather than overwriting it with the raw set.
2410    fn attach_metadata(&mut self, headers: &http::HeaderMap) {
2411        if let Err(e) = &mut self.outcome {
2412            e.set_response_headers(headers.clone());
2413            if let Some(trailers) = &self.trailers {
2414                e.set_trailers(error_metadata_from_trailers(trailers));
2415            }
2416        }
2417    }
2418
2419    fn replay<T>(&self) -> Result<Option<T>, ConnectError> {
2420        match &self.outcome {
2421            Ok(()) => Ok(None),
2422            Err(e) => Err(e.clone()),
2423        }
2424    }
2425}
2426
2427/// Lets `?` lift decode/transport/deadline errors out of the decode loop.
2428/// Every such site fires before any termination metadata exists, so
2429/// `trailers: None` is correct at all of them; ends that carry trailers
2430/// construct their `StreamEnd` explicitly.
2431impl From<ConnectError> for StreamEnd {
2432    fn from(e: ConnectError) -> Self {
2433        StreamEnd {
2434            outcome: Err(e),
2435            trailers: None,
2436        }
2437    }
2438}
2439
2440/// What one body poll produced.
2441enum BodyPoll {
2442    /// A DATA frame was appended to the decode buffer.
2443    Data,
2444    /// HTTP/2 (or HTTP/1.1 chunked) trailers — the body's final frame.
2445    Trailers(http::HeaderMap),
2446    /// Body exhausted.
2447    Eof,
2448}
2449
2450/// Response from a server-streaming RPC.
2451///
2452/// Provides incremental access to response messages as they arrive from the server.
2453/// Messages are decoded one at a time from the HTTP response body using the
2454/// [`message()`](ServerStream::message) method, which returns `Ok(None)` for
2455/// a clean end and `Err` for a failed RPC — `?` is the complete error
2456/// handling. Trailing metadata becomes available after the stream ends.
2457///
2458/// # Example
2459///
2460/// ```rust,ignore
2461/// let mut stream = call_server_stream(&transport, &config, "svc", "method", req, CallOptions::default()).await?;
2462/// println!("headers: {:?}", stream.headers());
2463/// while let Some(msg) = stream.message().await? {
2464///     println!("got message: {:?}", msg);
2465/// }
2466/// if let Some(trailers) = stream.trailers() {
2467///     println!("trailers: {:?}", trailers);
2468/// }
2469/// ```
2470pub struct ServerStream<B, RespView> {
2471    headers: http::HeaderMap,
2472    body: B,
2473    buf: BytesMut,
2474    encoding: Option<String>,
2475    compression: CompressionRegistry,
2476    codec_format: CodecFormat,
2477    protocol: Protocol,
2478    max_message_size: Option<usize>,
2479    deadline: Option<std::time::Instant>,
2480    /// The terminal record; `Some` once the stream has ended, by any cause.
2481    end: Option<StreamEnd>,
2482    /// Whether any body DATA frame arrived. Distinguishes a true
2483    /// Trailers-Only response (empty body; status rides the headers)
2484    /// from a stream that produced data and was then cut off.
2485    saw_body_data: bool,
2486    _phantom: PhantomData<RespView>,
2487}
2488
2489// Manual impl: the body type `B` (typically `hyper::body::Incoming`) isn't
2490// `Debug`, and we don't want to dump the partially-consumed `buf` anyway.
2491// Print the stream's observable state for test diagnostics.
2492impl<B, RespView> std::fmt::Debug for ServerStream<B, RespView> {
2493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2494        f.debug_struct("ServerStream")
2495            .field("protocol", &self.protocol)
2496            .field("codec_format", &self.codec_format)
2497            .field("encoding", &self.encoding)
2498            .field("ended", &self.end.is_some())
2499            .field(
2500                "error",
2501                &self.end.as_ref().and_then(|e| e.outcome.as_ref().err()),
2502            )
2503            .field(
2504                "has_trailers",
2505                &self.end.as_ref().is_some_and(|e| e.trailers.is_some()),
2506            )
2507            .field("buffered_bytes", &self.buf.len())
2508            .finish_non_exhaustive()
2509    }
2510}
2511
2512impl<B, RespView> ServerStream<B, RespView>
2513where
2514    B: Body<Data = Bytes> + Unpin,
2515    B::Error: std::fmt::Display,
2516    RespView: MessageView<'static> + Send,
2517    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
2518{
2519    /// Returns the response headers.
2520    #[must_use]
2521    pub fn headers(&self) -> &http::HeaderMap {
2522        &self.headers
2523    }
2524
2525    /// Fetch the next message from the stream.
2526    ///
2527    /// Returns `Ok(Some(msg))` for each message, `Ok(None)` when the stream
2528    /// ends **cleanly** (gRPC status OK / error-free END_STREAM), or
2529    /// `Err(...)` for everything else: protocol/decode/deadline errors *and*
2530    /// a server error carried in the stream's termination metadata (gRPC
2531    /// trailers, gRPC-Web trailer frame, or Connect END_STREAM envelope).
2532    /// `Ok(None)` means the RPC succeeded. Terminal errors arrive from
2533    /// `message()` itself, as in `tonic`.
2534    ///
2535    /// Every `Err` is terminal and sticky: the stream will never yield
2536    /// another message, subsequent calls return the same `Err` (the same
2537    /// policy as a failed stream construction; stronger than `tonic`, which
2538    /// yields the error once and then reads as a clean end), and recovery
2539    /// means making a new call — not re-polling this one. The terminal error also remains
2540    /// inspectable via [`error()`](Self::error), and
2541    /// [`trailers()`](Self::trailers) is populated when termination metadata
2542    /// was received — for both the `Ok(None)` and `Err` ends.
2543    ///
2544    /// If a deadline was set on this call (via [`CallOptions::with_timeout`]
2545    /// or [`ClientConfig::with_default_timeout`]), each `message()` poll is
2546    /// bounded by it — gRPC deadline semantics are whole-call, so a hung
2547    /// server won't block indefinitely (matching grpc-java and connect-go).
2548    ///
2549    /// # Errors
2550    ///
2551    /// A response body that ends without its protocol's termination
2552    /// metadata is not a clean end and returns `Err` rather than
2553    /// `Ok(None)`: `internal` for a Connect stream missing its
2554    /// END_STREAM envelope; for gRPC/gRPC-Web, `internal` when no
2555    /// trailers arrived at all, `unknown` when trailers arrived without a
2556    /// `grpc-status`, and `unknown` for a malformed `grpc-status` value —
2557    /// matching grpc-go's treatment of each case. A Trailers-Only response
2558    /// carrying `grpc-status: 0` in the headers (empty body) is a clean
2559    /// end.
2560    ///
2561    /// Whatever the cause, the returned error carries the response
2562    /// metadata: [`ConnectError::response_headers()`] always, and
2563    /// [`ConnectError::trailers()`] whenever termination metadata arrived.
2564    pub async fn message<M>(&mut self) -> Result<Option<crate::StreamMessage<M>>, ConnectError>
2565    where
2566        // `M` is an output parameter pinned to `RespView`'s owned message —
2567        // spelled this way round (rather than bounding `RespView::Owned`
2568        // directly) so the future stays `Send` for concrete generated view
2569        // types: projecting through the GAT in the bound trips rustc's
2570        // coroutine-witness auto-trait check (#214).
2571        RespView: MessageView<'static, Owned = M>,
2572        M: HasMessageView<View<'static> = RespView>,
2573    {
2574        // The outcome is immutable once reported: replay the terminal
2575        // record without re-entering the body.
2576        if let Some(end) = &self.end {
2577            return end.replay();
2578        }
2579        match self.next_message_or_end().await {
2580            Ok(msg) => Ok(Some(crate::StreamMessage::from_owned_view(msg))),
2581            // The single writer of the terminal record. `message_inner`
2582            // cannot end the stream without producing one — "ended without
2583            // recording why" is unrepresentable.
2584            Err(mut end) => {
2585                debug_assert!(self.end.is_none(), "terminal record written twice");
2586                end.attach_metadata(&self.headers);
2587                self.end.get_or_insert(end).replay()
2588            }
2589        }
2590    }
2591
2592    /// The decode loop. An `Err` here means **"the stream ended"**, not
2593    /// "failure" — the [`StreamEnd`] record says whether the end was
2594    /// clean (`outcome: Ok(())`) or a failure. Plain
2595    /// decode/transport/deadline errors lift into a `StreamEnd` via
2596    /// `From`. There is deliberately no way to exit this loop without
2597    /// producing the terminal record.
2598    async fn next_message_or_end(&mut self) -> Result<OwnedView<RespView>, StreamEnd> {
2599        loop {
2600            // For gRPC-Web, check for a complete trailer frame (flag 0x80)
2601            // before attempting envelope decode (which would treat 0x80 as
2602            // a data envelope flag rather than the gRPC-Web trailer sentinel).
2603            if matches!(self.protocol, Protocol::GrpcWeb)
2604                && self.buf.len() >= 5
2605                && self.buf[0] & 0x80 != 0
2606            {
2607                let trailer_len =
2608                    u32::from_be_bytes([self.buf[1], self.buf[2], self.buf[3], self.buf[4]])
2609                        as usize;
2610                // `saturating_add`: `trailer_len` is a server-controlled u32, so
2611                // on a 32-bit target (e.g. the supported `wasm32` gRPC-Web
2612                // client) `5 + trailer_len` can overflow `usize` and panic in a
2613                // debug build. Matches the sibling framing sites, which already
2614                // saturate. A saturated sum is never `<= buf.len()`, so an
2615                // over-large prefix simply waits for bytes that never arrive.
2616                if self.buf.len() >= trailer_len.saturating_add(5) {
2617                    // Complete trailer frame — parse and classify. An
2618                    // unparseable frame classifies as `None` (no usable
2619                    // termination metadata).
2620                    let decompression =
2621                        self.encoding.as_deref().map(|enc| (&self.compression, enc));
2622                    let parsed =
2623                        parse_grpc_web_trailer_frame_with_compression(&self.buf, decompression);
2624                    return Err(self.classify_grpc_end(parsed));
2625                }
2626                // Incomplete trailer frame — need more data, fall through
2627                // to poll_body below
2628            }
2629
2630            // Try to decode a complete envelope from the buffer.
2631            // Skip this for gRPC-Web when the buffer starts with 0x80 (trailer
2632            // flag) to avoid misinterpreting the trailer frame as a data message.
2633            let envelope_result = if matches!(self.protocol, Protocol::GrpcWeb)
2634                && !self.buf.is_empty()
2635                && self.buf[0] & 0x80 != 0
2636            {
2637                // We know the trailer frame is incomplete (checked above),
2638                // so signal that more data is needed.
2639                None
2640            } else {
2641                Envelope::decode_with_limit(
2642                    &mut self.buf,
2643                    self.max_message_size
2644                        .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE),
2645                )?
2646            };
2647
2648            match envelope_result {
2649                Some(envelope) => {
2650                    if envelope.is_end_stream() {
2651                        // Connect protocol end-of-stream envelope
2652                        return Err(self.process_end_stream(envelope));
2653                    }
2654
2655                    // Data envelope — decompress and decode
2656                    let data = self.decompress_envelope(envelope)?;
2657
2658                    // Check message size limit
2659                    if let Some(max_size) = self.max_message_size
2660                        && data.len() > max_size
2661                    {
2662                        return Err(ConnectError::new(
2663                            ErrorCode::ResourceExhausted,
2664                            format!("message size {} exceeds limit {}", data.len(), max_size),
2665                        )
2666                        .into());
2667                    }
2668
2669                    let msg = decode_response_view::<RespView>(data, self.codec_format)?;
2670                    return Ok(msg);
2671                }
2672                None => match self.poll_body().await? {
2673                    BodyPoll::Data => {} // loop back to try decoding again
2674                    BodyPoll::Trailers(trailers) => {
2675                        return Err(self.classify_grpc_end(Some(trailers)));
2676                    }
2677                    BodyPoll::Eof => {
2678                        if matches!(self.protocol, Protocol::Connect) {
2679                            // The HTTP body completed cleanly but the Connect
2680                            // envelope sequence is missing its terminus: a
2681                            // wire-level error, classified as `internal` the
2682                            // same way connect-go and other gRPC stacks treat a
2683                            // failed decompression or an unparseable response.
2684                            return Err(ConnectError::internal(
2685                                "Connect streaming response ended without END_STREAM envelope",
2686                            )
2687                            .into());
2688                        }
2689                        // gRPC-Web: preserved verbatim from the
2690                        // pre-refactor shape, and provably dead — the
2691                        // loop-top completeness check consumes any complete
2692                        // trailer frame before poll_body runs, and EOF
2693                        // appends nothing, so the remnant here is absent or
2694                        // incomplete and the parse returns `None`. Removal
2695                        // is a follow-up; either way classification sees
2696                        // "no usable termination metadata".
2697                        let parsed = if matches!(self.protocol, Protocol::GrpcWeb)
2698                            && !self.buf.is_empty()
2699                            && self.buf[0] & 0x80 != 0
2700                        {
2701                            let decompression =
2702                                self.encoding.as_deref().map(|enc| (&self.compression, enc));
2703                            parse_grpc_web_trailer_frame_with_compression(&self.buf, decompression)
2704                        } else {
2705                            None
2706                        };
2707                        return Err(self.classify_grpc_end(parsed));
2708                    }
2709                },
2710            }
2711        }
2712    }
2713
2714    /// The single classification site for gRPC/gRPC-Web stream ends:
2715    /// given the termination metadata that arrived (HTTP/2 trailers, a
2716    /// parsed gRPC-Web trailer frame, or `None` when nothing usable did),
2717    /// decide clean vs failed and produce the terminal record. Connect
2718    /// ends never come here — they classify in `process_end_stream` or
2719    /// the missing-END_STREAM arm.
2720    ///
2721    /// An end is only clean if a `grpc-status` actually arrived: in the
2722    /// trailers, or — for Trailers-Only responses (grpc-go emits these
2723    /// for OK ends with zero messages) — in the response headers, honored
2724    /// only while no body data has flowed (the unary path's
2725    /// has_body_data guard: a mid-stream cut after eager headers must not
2726    /// read as success). No status anywhere is indistinguishable from a
2727    /// mid-stream cut: past the whole-call deadline the deadline is what
2728    /// cut the stream (matches grpc-go / connect-go RST_STREAM CANCEL
2729    /// handling); with trailers present it's `unknown`, without any it's
2730    /// `internal` — each matching grpc-go.
2731    fn classify_grpc_end(&self, trailers: Option<http::HeaderMap>) -> StreamEnd {
2732        debug_assert!(
2733            matches!(self.protocol, Protocol::Grpc | Protocol::GrpcWeb),
2734            "Connect ends classify in process_end_stream / the missing-END_STREAM arm"
2735        );
2736        let outcome = match trailers.as_ref().and_then(parse_grpc_error_from_trailers) {
2737            // A server error — or a present-but-malformed status, which
2738            // the parse maps to `unknown` — ends the RPC in failure.
2739            Some(err) => Err(err),
2740            None => {
2741                let has_status = |h: &http::HeaderMap| h.contains_key("grpc-status");
2742                let trailers_only = !self.saw_body_data;
2743                if trailers.as_ref().is_some_and(has_status)
2744                    || (trailers_only && has_status(&self.headers))
2745                {
2746                    Ok(())
2747                } else if deadline_elapsed(self.deadline) {
2748                    Err(ConnectError::deadline_exceeded("request timeout"))
2749                } else if trailers.is_some() {
2750                    Err(ConnectError::new(
2751                        ErrorCode::Unknown,
2752                        "protocol error: grpc-status missing from trailers",
2753                    ))
2754                } else {
2755                    Err(ConnectError::internal("stream ended without grpc-status"))
2756                }
2757            }
2758        };
2759        StreamEnd { outcome, trailers }
2760    }
2761
2762    /// Returns the trailing metadata, if available.
2763    ///
2764    /// Only populated after [`message()`](Self::message) reports the end of
2765    /// the stream, and only when termination metadata was received — for
2766    /// both the `Ok(None)` and `Err` ends.
2767    #[must_use]
2768    pub fn trailers(&self) -> Option<&http::HeaderMap> {
2769        self.end.as_ref().and_then(|e| e.trailers.as_ref())
2770    }
2771
2772    /// Returns the terminal error that ended the stream, if any — a server
2773    /// error from the termination metadata (gRPC trailers / Connect
2774    /// END_STREAM), or a decode/transport/deadline failure.
2775    ///
2776    /// [`message()`](Self::message) already returns this same error, so most
2777    /// callers never need this accessor; it exists for post-hoc inspection
2778    /// alongside [`trailers()`](Self::trailers).
2779    #[must_use]
2780    pub fn error(&self) -> Option<&ConnectError> {
2781        self.end.as_ref().and_then(|e| e.outcome.as_ref().err())
2782    }
2783
2784    /// Poll the body for the next frame. A pure transport reader: it
2785    /// buffers data, returns trailers as a value, and never touches the
2786    /// terminal record.
2787    ///
2788    /// Buffer growth is bounded: if the accumulated bytes exceed the expected
2789    /// maximum in-flight envelope size, return `ResourceExhausted` rather than
2790    /// continuing to buffer. This prevents a malicious server from trickling
2791    /// bytes indefinitely without ever completing an envelope.
2792    async fn poll_body(&mut self) -> Result<BodyPoll, ConnectError> {
2793        // Enough for one complete envelope at the max message size, plus
2794        // one header's worth of slack (next envelope's header may arrive in
2795        // the same TCP frame), plus 64 KiB for gRPC-Web trailer frames.
2796        let max_buf_size = self
2797            .max_message_size
2798            .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE)
2799            .saturating_add(2 * crate::envelope::HEADER_SIZE)
2800            .saturating_add(RESPONSE_BUFFER_TRAILER_SLACK);
2801
2802        loop {
2803            // The whole-call deadline bounds each frame poll. The
2804            // equivalence with bounding the entire decode loop rests on
2805            // three facts: the deadline is an absolute instant, all work
2806            // between frame polls is non-yielding, and `timeout_at` polls
2807            // the inner future before the timer (a Ready frame at the
2808            // deadline wins, in both shapes). It is what lets every
2809            // terminal cause exit `next_message_or_end` as a `StreamEnd`. (A
2810            // relative per-poll timeout would break it;
2811            // `deadline_bounds_multi_frame_message` pins that.)
2812            let deadline = self.deadline;
2813            let frame = with_deadline(deadline, async {
2814                Ok(Pin::new(&mut self.body).frame().await)
2815            })
2816            .await?;
2817
2818            match frame {
2819                None => return Ok(BodyPoll::Eof),
2820                Some(Ok(frame)) => {
2821                    if frame.is_data() {
2822                        if let Ok(data) = frame.into_data() {
2823                            if !data.is_empty() {
2824                                self.saw_body_data = true;
2825                            }
2826                            if self.buf.len().saturating_add(data.len()) > max_buf_size {
2827                                return Err(ConnectError::resource_exhausted(format!(
2828                                    "response buffer exceeds limit {max_buf_size}"
2829                                )));
2830                            }
2831                            self.buf.extend_from_slice(&data);
2832                            return Ok(BodyPoll::Data);
2833                        }
2834                    } else if frame.is_trailers()
2835                        && let Ok(trailers) = frame.into_trailers()
2836                        && matches!(self.protocol, Protocol::Grpc | Protocol::GrpcWeb)
2837                    {
2838                        // HTTP/2 or HTTP/1.1 chunked trailers — used by
2839                        // gRPC/gRPC-Web. (Connect has no trailer semantics;
2840                        // such frames are skipped.)
2841                        return Ok(BodyPoll::Trailers(trailers));
2842                    }
2843                }
2844                Some(Err(e)) => {
2845                    return Err(classify_body_read_error(
2846                        "failed to read response body",
2847                        &e,
2848                        deadline,
2849                    ));
2850                }
2851            }
2852        }
2853    }
2854
2855    /// Decompress a data envelope if needed.
2856    fn decompress_envelope(&self, envelope: Envelope) -> Result<Bytes, ConnectError> {
2857        if envelope.is_compressed() {
2858            let encoding = self.encoding.as_deref().ok_or_else(|| {
2859                ConnectError::internal(
2860                    "received compressed message without content-encoding header",
2861                )
2862            })?;
2863            let max_size = self
2864                .max_message_size
2865                .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE);
2866            self.compression
2867                .decompress_with_limit(encoding, envelope.data, max_size)
2868                .map_err(map_response_decompression_error)
2869        } else {
2870            Ok(envelope.data)
2871        }
2872    }
2873
2874    /// Classify the Connect END_STREAM envelope into the terminal record.
2875    fn process_end_stream(&self, envelope: Envelope) -> StreamEnd {
2876        let end_stream_data = match self.decompress_envelope(envelope) {
2877            Ok(data) => data,
2878            Err(e) => return e.into(),
2879        };
2880
2881        let end_stream = match parse_connect_end_stream(&end_stream_data) {
2882            Ok(end_stream) => end_stream,
2883            Err(e) => return e.into(),
2884        };
2885
2886        let trailers = end_stream.metadata.map(|metadata| {
2887            let mut trailers = http::HeaderMap::new();
2888            append_metadata_capped(&mut trailers, metadata);
2889            trailers
2890        });
2891
2892        let outcome = match end_stream.error {
2893            Some(err) => Err(end_stream_error_to_connect_error(err)),
2894            None => Ok(()),
2895        };
2896
2897        StreamEnd { outcome, trailers }
2898    }
2899}
2900
2901/// Make a server-streaming RPC call.
2902///
2903/// Sends a single request and returns a [`ServerStream`] that yields response
2904/// messages incrementally as they arrive. Use [`ServerStream::message()`] to
2905/// read messages one at a time.
2906///
2907/// # Errors
2908///
2909/// Returns immediately with an error if:
2910/// - The request cannot be encoded or sent
2911/// - The server responds with a non-200 status (protocol-level error)
2912///
2913/// Errors that occur during the stream (e.g., in gRPC trailers or the
2914/// END_STREAM envelope) are returned by [`ServerStream::message()`].
2915///
2916/// # Cancellation
2917///
2918/// Dropping the returned future or hitting its deadline drops the in-flight
2919/// transport send with it, so a request that had not finished sending may
2920/// never reach the server.
2921pub async fn call_server_stream<T, Req, RespView>(
2922    transport: &T,
2923    config: &ClientConfig,
2924    service: &str,
2925    method: &str,
2926    request: Req,
2927    options: CallOptions,
2928) -> Result<ServerStream<T::ResponseBody, RespView>, ConnectError>
2929where
2930    T: ClientTransport,
2931    <T::ResponseBody as Body>::Error: std::fmt::Display,
2932    Req: buffa::Message + crate::codec::JsonSerialize,
2933    RespView: MessageView<'static> + Send,
2934    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
2935{
2936    let options = effective_options(config, options);
2937
2938    // Build the full URI from base_uri and service/method path
2939    let base_str = config.base_uri.to_string();
2940    let base_str = base_str.trim_end_matches('/');
2941    let full_uri = format!("{base_str}/{service}/{method}");
2942    let uri: Uri = full_uri
2943        .parse()
2944        .map_err(|e| ConnectError::internal(format!("invalid URI: {e}")))?;
2945
2946    // Encode the request body
2947    let body = match config.codec_format {
2948        CodecFormat::Proto => request.encode_to_bytes(),
2949        CodecFormat::Json => encode_json(&request)?,
2950    };
2951
2952    // Compress and envelope-frame the request body (streaming protocol
2953    // requires envelope framing).
2954    let compression_for_encoder = config.request_compression.as_ref().map(|enc| {
2955        (
2956            std::sync::Arc::new(config.compression.clone()),
2957            enc.as_str(),
2958        )
2959    });
2960    let mut encoder = crate::envelope::EnvelopeEncoder::new(
2961        compression_for_encoder,
2962        config.compression_policy.with_override(options.compress),
2963    );
2964    let mut request_buf = bytes::BytesMut::new();
2965    tokio_util::codec::Encoder::encode(&mut encoder, body, &mut request_buf)?;
2966    let request_body = request_buf.freeze();
2967
2968    // Compute deadline BEFORE sending, matching Go's ctx.Deadline() semantics
2969    let deadline = client_deadline(options.timeout, config.protocol);
2970
2971    // Build the HTTP request with protocol-aware streaming headers
2972    let mut builder = Request::builder().method(http::Method::POST).uri(uri);
2973    builder = add_streaming_request_headers(builder, config, options.timeout);
2974
2975    // Merge user-provided headers (last, so they can override anything)
2976    let headers = builder.headers_mut().unwrap();
2977    for (name, value) in &options.headers {
2978        headers.append(name.clone(), value.clone());
2979    }
2980
2981    let http_request = builder
2982        .body(full_body(request_body))
2983        .map_err(|e| ConnectError::internal(format!("failed to build request: {e}")))?;
2984
2985    // Enforce client-side deadline on send + header parsing. Ongoing
2986    // message() reads are also bounded by the same deadline (inside
2987    // ServerStream::message) — gRPC deadline semantics are whole-call.
2988    with_deadline(deadline, async {
2989        let response = transport
2990            .send(http_request)
2991            .await
2992            .map_err(|e| map_transport_send_error(e, "request failed"))?;
2993
2994        make_server_stream(
2995            response,
2996            config.protocol,
2997            &config.compression,
2998            config.codec_format,
2999            options.max_message_size,
3000            deadline,
3001        )
3002        .await
3003    })
3004    .await
3005}
3006
3007/// Construct a [`ServerStream`] from a streaming HTTP response.
3008///
3009/// Handles trailers-only gRPC error detection, non-200 Connect error body
3010/// parsing, and response encoding extraction. Used by both [`call_server_stream`]
3011/// (passing fields from `&ClientConfig`) and [`BidiStream::message`] (passing
3012/// fields from its owned `StreamConfig` snapshot).
3013///
3014/// Takes individual config fields instead of `&ClientConfig` so callers that
3015/// need to capture config by value (like `BidiStream`, which outlives the
3016/// borrow) can share the same code path.
3017async fn make_server_stream<B, RespView>(
3018    response: Response<B>,
3019    protocol: Protocol,
3020    compression: &CompressionRegistry,
3021    codec_format: CodecFormat,
3022    max_message_size: Option<usize>,
3023    deadline: Option<std::time::Instant>,
3024) -> Result<ServerStream<B, RespView>, ConnectError>
3025where
3026    B: Body<Data = Bytes> + Send,
3027    B::Error: std::fmt::Display,
3028    RespView: MessageView<'static> + Send,
3029    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
3030{
3031    let response_headers = response.headers().clone();
3032    let status = response.status();
3033
3034    // For gRPC, check for trailers-only error response
3035    if matches!(protocol, Protocol::Grpc | Protocol::GrpcWeb)
3036        && let Some(mut err) = parse_grpc_error_from_trailers(&response_headers)
3037    {
3038        err.set_response_headers(response_headers);
3039        return Err(err);
3040    }
3041
3042    // Non-200 responses are protocol errors
3043    if !status.is_success() {
3044        if matches!(protocol, Protocol::Connect) {
3045            let error_encoding = response_headers
3046                .get(http::header::CONTENT_ENCODING)
3047                .and_then(|v| v.to_str().ok())
3048                .map(|s| s.to_owned());
3049
3050            let stream_max_err_size =
3051                max_message_size.unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE);
3052
3053            // Reading the error body can itself fail — oversized, deadline
3054            // elapsed, transport reset — and that error is as terminal as the
3055            // one the body was going to describe. Carry the response headers,
3056            // the same way the unary path does. No trailers exist yet here.
3057            let body = collect_body_bounded(response.into_body(), stream_max_err_size, deadline)
3058                .await
3059                .map_err(|mut e| {
3060                    e.set_response_headers(response_headers.clone());
3061                    e
3062                })?;
3063
3064            // Decompress if the server set Content-Encoding. On failure,
3065            // fall through to the generic HTTP-status error below.
3066            let body = match error_encoding {
3067                Some(encoding) => {
3068                    match compression.decompress_with_limit(&encoding, body, stream_max_err_size) {
3069                        Ok(decompressed) => Some(decompressed),
3070                        Err(e) => {
3071                            tracing::debug!(
3072                                "failed to decompress Connect error response ({encoding}): {e}"
3073                            );
3074                            None
3075                        }
3076                    }
3077                }
3078                None => Some(body),
3079            };
3080
3081            if let Some(body) = body
3082                && let Ok(error) = serde_json::from_slice::<ConnectErrorResponse>(&body)
3083            {
3084                let code = error
3085                    .code
3086                    .as_deref()
3087                    .and_then(|s| s.parse::<ErrorCode>().ok())
3088                    .unwrap_or_else(|| http_status_to_error_code(status));
3089                let mut err = ConnectError::new(code, error.message.unwrap_or_default());
3090                err.details = error.details;
3091                err.set_response_headers(response_headers);
3092                return Err(err);
3093            }
3094        }
3095
3096        let code = http_status_to_error_code(status);
3097        let mut err = ConnectError::new(code, format!("HTTP error {}", status.as_u16()));
3098        err.set_response_headers(response_headers);
3099        return Err(err);
3100    }
3101
3102    // Get the response encoding for compressed envelopes (protocol-aware header)
3103    let encoding = response_headers
3104        .get(protocol.content_encoding_header())
3105        .and_then(|v| v.to_str().ok())
3106        .map(|s| s.to_owned());
3107
3108    Ok(ServerStream {
3109        headers: response_headers,
3110        body: response.into_body(),
3111        buf: BytesMut::new(),
3112        encoding,
3113        compression: compression.clone(),
3114        codec_format,
3115        protocol,
3116        max_message_size,
3117        deadline,
3118        end: None,
3119        saw_body_data: false,
3120        _phantom: PhantomData,
3121    })
3122}
3123
3124// ============================================================================
3125// BidiStream — bidirectional streaming client
3126// ============================================================================
3127
3128/// A request body that pulls envelope-encoded frames from an mpsc channel.
3129///
3130/// Used as the request body for bidirectional streaming calls.
3131/// [`BidiSendHalf::send`] pushes encoded envelopes to the channel's sender;
3132/// dropping the sender (via [`BidiSendHalf::close_send`]) closes the body,
3133/// signalling EOF to the server.
3134struct ChannelBody {
3135    rx: tokio::sync::mpsc::Receiver<Result<Bytes, ConnectError>>,
3136}
3137
3138impl Body for ChannelBody {
3139    type Data = Bytes;
3140    type Error = ConnectError;
3141
3142    fn poll_frame(
3143        mut self: Pin<&mut Self>,
3144        cx: &mut std::task::Context<'_>,
3145    ) -> std::task::Poll<Option<Result<http_body::Frame<Bytes>, ConnectError>>> {
3146        self.rx
3147            .poll_recv(cx)
3148            .map(|opt| opt.map(|r| r.map(http_body::Frame::data)))
3149    }
3150}
3151
3152/// A request body that lazily encodes messages from the caller's stream
3153/// into envelope frames as the transport polls for body data.
3154///
3155/// Used by [`call_client_stream`]: making the stream *be* the body hands
3156/// upload liveness to the HTTP layer. The transport polls for the next
3157/// frame only while it can send (backpressure is HTTP/2 flow control), a
3158/// server that ends the RPC early makes the transport stop polling and
3159/// drop the body, and a server that sends response headers early while
3160/// still reading the upload keeps receiving frames — none of which needs a
3161/// library-side pump loop.
3162///
3163/// The stream is held in a [`sync_wrapper::SyncWrapper`] so the body is
3164/// `Sync` (as [`ClientBody`]'s boxing requires) without demanding `Sync`
3165/// of the caller's stream — the wrapper only ever hands out `&mut` access.
3166#[pin_project::pin_project]
3167struct EncodingBody<S> {
3168    #[pin]
3169    stream: sync_wrapper::SyncWrapper<S>,
3170    encoder: crate::envelope::EnvelopeEncoder,
3171    codec_format: CodecFormat,
3172    /// Mirror of an encode error also emitted through the body, letting the
3173    /// call report the precise error instead of a transport-level failure.
3174    error: std::sync::Arc<std::sync::Mutex<Option<ConnectError>>>,
3175    /// Set on an encode error or stream exhaustion; the body then reports
3176    /// end-of-stream without polling the (possibly non-fused) stream again.
3177    done: bool,
3178}
3179
3180impl<S, Req> Body for EncodingBody<S>
3181where
3182    S: Stream<Item = Req>,
3183    Req: buffa::Message + crate::codec::JsonSerialize,
3184{
3185    type Data = Bytes;
3186    type Error = ConnectError;
3187
3188    fn poll_frame(
3189        self: Pin<&mut Self>,
3190        cx: &mut std::task::Context<'_>,
3191    ) -> std::task::Poll<Option<Result<http_body::Frame<Bytes>, ConnectError>>> {
3192        use std::task::Poll;
3193
3194        let this = self.project();
3195        if *this.done {
3196            return Poll::Ready(None);
3197        }
3198
3199        let Some(request) = std::task::ready!(this.stream.get_pin_mut().poll_next(cx)) else {
3200            // `Stream` gives no post-`None` guarantee, so never poll the
3201            // (possibly non-fused) stream again.
3202            *this.done = true;
3203            return Poll::Ready(None);
3204        };
3205
3206        let mut record_error = |err: &ConnectError| {
3207            *this.done = true;
3208            *this
3209                .error
3210                .lock()
3211                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(err.clone());
3212        };
3213
3214        let msg_bytes = match this.codec_format {
3215            CodecFormat::Proto => request.encode_to_bytes(),
3216            CodecFormat::Json => match encode_json(&request) {
3217                Ok(bytes) => bytes,
3218                Err(err) => {
3219                    record_error(&err);
3220                    return Poll::Ready(Some(Err(err)));
3221                }
3222            },
3223        };
3224
3225        let mut envelope_buf = BytesMut::new();
3226        match tokio_util::codec::Encoder::encode(this.encoder, msg_bytes, &mut envelope_buf) {
3227            Ok(()) => Poll::Ready(Some(Ok(http_body::Frame::data(envelope_buf.freeze())))),
3228            Err(err) => {
3229                record_error(&err);
3230                Poll::Ready(Some(Err(err)))
3231            }
3232        }
3233    }
3234}
3235
3236/// State machine for [`BidiRecvHalf`], the receive side of a [`BidiStream`].
3237///
3238/// The transport send is spawned so the HTTP request makes progress
3239/// immediately (connect, handshake, start streaming the request body from
3240/// [`ChannelBody`]) regardless of when the caller first calls
3241/// [`BidiStream::message`]. Without the spawn, a transport whose `send()`
3242/// future contains the actual connect/stream work (e.g.,
3243/// [`SharedHttp2Connection`]) would not initiate the request until
3244/// `message()` is called — so the half-duplex pattern (send all, then
3245/// read) would buffer into the 32-deep mpsc with nobody draining it, and
3246/// deadlock on the 33rd send.
3247///
3248/// Response initialization is still lazy: `message()` first awaits response
3249/// HEADERS, then constructs the [`ServerStream`]. Both pending operations stay
3250/// in this state machine while awaited, so cancelling `message()` does not
3251/// discard either the response task or a suspended construction step such as
3252/// Connect error-body parsing. Dropping the [`BidiRecvHalf`] that owns this
3253/// state (or failing the call at its deadline) aborts the in-flight task
3254/// instead.
3255enum RecvState<B, RespView> {
3256    /// Request initiated in a spawned task; response HEADERS not yet
3257    /// received. Awaiting the handle yields the [`Response`] once hyper
3258    /// reads the HEADERS frame.
3259    AwaitingHeaders(tokio::task::JoinHandle<Result<Response<B>, ConnectError>>),
3260    /// HEADERS received; response-side stream construction is in progress.
3261    Constructing(tokio::task::JoinHandle<Result<Box<ServerStream<B, RespView>>, ConnectError>>),
3262    /// HEADERS received; response-side decoding delegates to [`ServerStream`].
3263    Ready(Box<ServerStream<B, RespView>>),
3264    /// Transport error, deadline, or make_server_stream error. Terminal state.
3265    Failed(ConnectError),
3266}
3267
3268/// A bidirectional streaming RPC in progress.
3269///
3270/// Returned from [`call_bidi_stream`]. Provides a `send`/`close_send`/`message`
3271/// API modeled on connect-go's `BidiStreamForClient`.
3272///
3273/// # Half-duplex vs full-duplex
3274///
3275/// The Connect spec supports both. Half-duplex (send all, then receive all)
3276/// works on HTTP/1.1 and HTTP/2. Full-duplex (interleaved send/receive) requires
3277/// HTTP/2. This type does not distinguish — it's the caller's responsibility to
3278/// respect the protocol in use. On HTTP/1.1, calling `message()` before
3279/// `close_send()` will block until the request body is complete.
3280///
3281/// To drive the two sides from separate tasks, split the stream into
3282/// independently owned halves with [`into_split()`](Self::into_split).
3283///
3284/// # Cancellation
3285///
3286/// Dropping the `BidiStream` cancels the call: any in-flight initialization
3287/// task is aborted, which resets the underlying transport stream. Request
3288/// messages accepted by [`send()`](Self::send) but not yet transmitted may
3289/// never reach the server — a caller that needs the request delivered must
3290/// drive the call to completion via [`message()`](Self::message) before
3291/// dropping. Cancelling an individual `message()` future is safe and
3292/// resumable — see [`message()`](Self::message).
3293///
3294/// # Example
3295///
3296/// ```rust,ignore
3297/// let mut stream = call_bidi_stream(&transport, &config, "svc", "method", CallOptions::default()).await?;
3298/// stream.send(request1).await?;
3299/// stream.send(request2).await?;
3300/// stream.close_send();
3301/// // `Ok(None)` means a clean end; a failed RPC surfaces as `Err`,
3302/// // so `?` is the complete error handling.
3303/// while let Some(msg) = stream.message().await? {
3304///     println!("got: {msg:?}");
3305/// }
3306/// ```
3307pub struct BidiStream<B, Req, RespView> {
3308    // Field order is load-bearing for drop: `send` drops first (clean
3309    // request-body EOF), then `recv`'s Drop aborts any in-flight
3310    // initialization task. The glue between the two field drops is
3311    // synchronous, so the spawned task cannot advance in between — the
3312    // abort still catches anything the old whole-struct Drop would have.
3313    send: BidiSendHalf<Req>,
3314    recv: BidiRecvHalf<B, RespView>,
3315}
3316
3317// Manual impl: delegate to the halves, which carry the useful state.
3318impl<B, Req, RespView> std::fmt::Debug for BidiStream<B, Req, RespView> {
3319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3320        f.debug_struct("BidiStream")
3321            .field("send", &self.send)
3322            .field("recv", &self.recv)
3323            .finish()
3324    }
3325}
3326
3327/// The send half of a [`BidiStream`], returned by
3328/// [`BidiStream::into_split`].
3329///
3330/// Owns the request side of the RPC: [`send()`](Self::send) and
3331/// [`close_send()`](Self::close_send). Dropping the half without calling
3332/// `close_send` closes the send side the same way (the request body ends
3333/// cleanly); the RPC itself stays alive as long as the [`BidiRecvHalf`]
3334/// does. The halves cannot be recombined into a [`BidiStream`].
3335pub struct BidiSendHalf<Req> {
3336    tx: Option<tokio::sync::mpsc::Sender<Result<Bytes, ConnectError>>>,
3337    encoder: crate::envelope::EnvelopeEncoder,
3338    codec_format: CodecFormat,
3339    /// Copy of the whole-call deadline; checked before each send.
3340    deadline: Option<std::time::Instant>,
3341    _req: PhantomData<Req>,
3342}
3343
3344impl<Req> std::fmt::Debug for BidiSendHalf<Req> {
3345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3346        f.debug_struct("BidiSendHalf")
3347            .field("send_closed", &self.tx.is_none())
3348            .field("codec_format", &self.codec_format)
3349            .finish_non_exhaustive()
3350    }
3351}
3352
3353/// The receive half of a [`BidiStream`], returned by
3354/// [`BidiStream::into_split`].
3355///
3356/// Owns the response side of the RPC: [`message()`](Self::message) plus the
3357/// [`headers()`](Self::headers), [`trailers()`](Self::trailers), and
3358/// [`error()`](Self::error) accessors. Dropping this half cancels the RPC
3359/// (any in-flight initialization task is aborted and the transport stream
3360/// is reset), after which sends on the [`BidiSendHalf`] fail. The halves
3361/// cannot be recombined into a [`BidiStream`].
3362pub struct BidiRecvHalf<B, RespView> {
3363    // State machine: AwaitingHeaders -> Constructing -> Ready or Failed
3364    recv: RecvState<B, RespView>,
3365    /// Config snapshot for constructing ServerStream when headers arrive.
3366    /// Captured by value (not &) because the stream outlives call_bidi_stream.
3367    stream_config: StreamConfig,
3368}
3369
3370impl<B, RespView> std::fmt::Debug for BidiRecvHalf<B, RespView> {
3371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3372        let (recv_state, recv_error) = match &self.recv {
3373            RecvState::AwaitingHeaders(_) => ("AwaitingHeaders", None),
3374            RecvState::Constructing(_) => ("Constructing", None),
3375            RecvState::Ready(_) => ("Ready", None),
3376            RecvState::Failed(err) => ("Failed", Some(err)),
3377        };
3378        f.debug_struct("BidiRecvHalf")
3379            .field("recv_state", &recv_state)
3380            .field("protocol", &self.stream_config.protocol)
3381            .field("codec_format", &self.stream_config.codec_format)
3382            .field("recv_error", &recv_error)
3383            .finish_non_exhaustive()
3384    }
3385}
3386
3387// Dropping the receive half aborts any in-flight initialization task.
3388// Without this, a task left in `AwaitingHeaders` or `Constructing` would
3389// detach on drop and — absent a call deadline — could be pinned indefinitely
3390// by a server that stalls response HEADERS or a Connect error body without
3391// ever ending the stream. Abandoning the receive half abandons the RPC, so
3392// nothing can consume the task's result anyway. (This also covers dropping
3393// a whole `BidiStream`, which contains this half.)
3394impl<B, RespView> Drop for BidiRecvHalf<B, RespView> {
3395    fn drop(&mut self) {
3396        match &self.recv {
3397            RecvState::AwaitingHeaders(task) => task.abort(),
3398            RecvState::Constructing(task) => task.abort(),
3399            RecvState::Ready(_) | RecvState::Failed(_) => {}
3400        }
3401    }
3402}
3403
3404/// Snapshot of ClientConfig fields needed to construct the inner ServerStream
3405/// once response headers arrive. Avoids holding a borrow across awaits.
3406#[derive(Debug)]
3407struct StreamConfig {
3408    protocol: Protocol,
3409    codec_format: CodecFormat,
3410    compression: CompressionRegistry,
3411    max_message_size: Option<usize>,
3412    deadline: Option<std::time::Instant>,
3413}
3414
3415impl<Req> BidiSendHalf<Req>
3416where
3417    Req: buffa::Message + crate::codec::JsonSerialize,
3418{
3419    /// Send a request message.
3420    ///
3421    /// # Errors
3422    ///
3423    /// Returns an error if [`close_send`](Self::close_send) was already
3424    /// called, if the whole-call deadline has passed, or if the server has
3425    /// closed the stream. In the latter case, receive on the other half —
3426    /// [`BidiRecvHalf::message()`] — to retrieve the server's error. (The
3427    /// same error is returned when the [`BidiRecvHalf`] was dropped, which
3428    /// cancels the RPC.)
3429    pub async fn send(&mut self, msg: Req) -> Result<(), ConnectError> {
3430        // Check the whole-call deadline before each send, matching
3431        // connect-go's ctx.Err() check in duplexHTTPCall.Send().
3432        if let Some(d) = self.deadline
3433            && std::time::Instant::now() >= d
3434        {
3435            return Err(ConnectError::deadline_exceeded(
3436                "client-side deadline exceeded",
3437            ));
3438        }
3439
3440        let Some(tx) = &self.tx else {
3441            return Err(ConnectError::internal("send after close_send"));
3442        };
3443
3444        // Encode message (proto or JSON) then envelope-frame (with optional
3445        // compression). Same logic as call_server_stream's request encoding.
3446        let msg_bytes = match self.codec_format {
3447            CodecFormat::Proto => msg.encode_to_bytes(),
3448            CodecFormat::Json => encode_json(&msg)?,
3449        };
3450
3451        let mut envelope_buf = BytesMut::new();
3452        tokio_util::codec::Encoder::encode(&mut self.encoder, msg_bytes, &mut envelope_buf)?;
3453
3454        tx.send(Ok(envelope_buf.freeze())).await.map_err(|_| {
3455            // Channel receiver dropped — the body stream has been consumed
3456            // or the HTTP request task has exited. The server likely sent
3457            // an error; message() will surface it.
3458            ConnectError::unavailable("stream closed by server (call message() for error)")
3459        })
3460    }
3461
3462    /// Close the send side of the stream. Idempotent.
3463    ///
3464    /// After this, only receiving is possible. For half-duplex use
3465    /// (HTTP/1.1), this must be called before receiving. Dropping the half
3466    /// has the same effect.
3467    pub fn close_send(&mut self) {
3468        self.tx = None; // drop sender → channel closes → body signals EOF
3469    }
3470}
3471
3472impl<B, RespView> BidiRecvHalf<B, RespView>
3473where
3474    B: Body<Data = Bytes> + Send + Unpin,
3475    B::Error: std::fmt::Display,
3476    RespView: MessageView<'static> + Send,
3477    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
3478{
3479    /// Receive the next response message.
3480    ///
3481    /// The first call awaits response headers (lazily, so full-duplex
3482    /// servers that wait for a request before sending headers don't deadlock).
3483    /// Subsequent calls decode envelopes from the response body stream.
3484    /// If this future is dropped while response initialization is still pending,
3485    /// the initialization remains in the stream and the next `message()` call
3486    /// resumes it. Actual initialization failures remain terminal and sticky.
3487    ///
3488    /// # Errors
3489    ///
3490    /// Returns `Ok(None)` only when the server finished **cleanly**; a
3491    /// server error carried in the termination metadata is returned as
3492    /// `Err`, sticky across calls — see [`ServerStream::message()`] for the
3493    /// full contract.
3494    pub async fn message<M>(&mut self) -> Result<Option<crate::StreamMessage<M>>, ConnectError>
3495    where
3496        // Same output-parameter shape as `ServerStream::message` — see the
3497        // bound comment there (#214). `B` and `RespView` must also be
3498        // `'static` because response-side construction is retained in a
3499        // spawned task while the caller's `message()` future may be dropped.
3500        B: 'static,
3501        RespView: MessageView<'static, Owned = M> + 'static,
3502        M: HasMessageView<View<'static> = RespView>,
3503    {
3504        loop {
3505            match &mut self.recv {
3506                RecvState::AwaitingHeaders(task) => {
3507                    // Bound the response-HEADERS wait by the whole-call
3508                    // deadline. The JoinHandle stays in `self.recv` while it
3509                    // is pending, so cancelling this `message()` future does
3510                    // not detach the task or lose its eventual response.
3511                    let response = match with_deadline(self.stream_config.deadline, async {
3512                        // Reborrow rather than move so `task` stays usable
3513                        // for the abort in the failure arm below.
3514                        (&mut *task).await.map_err(|e| {
3515                            // JoinError's Display already distinguishes
3516                            // panic from cancellation.
3517                            ConnectError::internal(format!("transport send task failed: {e}"))
3518                        })?
3519                    })
3520                    .await
3521                    {
3522                        Ok(response) => response,
3523                        Err(e) => {
3524                            // Deadline (or join) failure is terminal: abort
3525                            // the response task rather than detaching it —
3526                            // the RPC is dead, so nothing will ever consume
3527                            // its result. No-op if the task already finished.
3528                            task.abort();
3529                            self.recv = RecvState::Failed(e.clone());
3530                            return Err(e);
3531                        }
3532                    };
3533
3534                    let protocol = self.stream_config.protocol;
3535                    let codec_format = self.stream_config.codec_format;
3536                    let compression = self.stream_config.compression.clone();
3537                    let max_message_size = self.stream_config.max_message_size;
3538                    let deadline = self.stream_config.deadline;
3539
3540                    let construct_task = tokio::spawn(async move {
3541                        // `make_server_stream` can await while collecting a
3542                        // non-200 Connect error body, before a `ServerStream`
3543                        // exists to enforce the call deadline.
3544                        let stream = with_deadline(
3545                            deadline,
3546                            make_server_stream(
3547                                response,
3548                                protocol,
3549                                &compression,
3550                                codec_format,
3551                                max_message_size,
3552                                deadline,
3553                            ),
3554                        )
3555                        .await?;
3556
3557                        Ok(Box::new(stream))
3558                    });
3559
3560                    self.recv = RecvState::Constructing(construct_task);
3561                }
3562                RecvState::Constructing(task) => {
3563                    // The construction task stays in `self.recv` while it is
3564                    // pending, so cancellation during Connect error-body
3565                    // collection can be resumed by the next `message()` call.
3566                    let result = match task.await {
3567                        Ok(result) => result,
3568                        Err(e) => Err(ConnectError::internal(format!(
3569                            "response stream construction task failed: {e}"
3570                        ))),
3571                    };
3572
3573                    match result {
3574                        Ok(stream) => self.recv = RecvState::Ready(stream),
3575                        Err(e) => {
3576                            self.recv = RecvState::Failed(e.clone());
3577                            return Err(e);
3578                        }
3579                    }
3580                }
3581                RecvState::Ready(stream) => return stream.message().await,
3582                RecvState::Failed(e) => return Err(e.clone()),
3583            }
3584        }
3585    }
3586
3587    /// Response headers. `None` until the first [`message()`](Self::message)
3588    /// call completes response initialization (a cancelled first `message()`
3589    /// can leave this `None` even after the HEADERS frame arrived).
3590    #[must_use]
3591    pub fn headers(&self) -> Option<&http::HeaderMap> {
3592        match &self.recv {
3593            RecvState::Ready(s) => Some(s.headers()),
3594            _ => None,
3595        }
3596    }
3597
3598    /// Trailing metadata. Only populated after [`message()`](Self::message)
3599    /// reports the end of the stream (`Ok(None)` or the terminal `Err`).
3600    #[must_use]
3601    pub fn trailers(&self) -> Option<&http::HeaderMap> {
3602        match &self.recv {
3603            RecvState::Ready(s) => s.trailers(),
3604            _ => None,
3605        }
3606    }
3607
3608    /// Terminal error that ended the stream, if any — a server error from
3609    /// the END_STREAM envelope (Connect) or trailers (gRPC), or a
3610    /// decode/transport/deadline failure. [`message()`](Self::message)
3611    /// already returns this same error, so most callers never need this
3612    /// accessor; it exists for post-hoc inspection alongside
3613    /// [`trailers()`](Self::trailers). Returns `None` while response
3614    /// initialization is still in progress — including after a cancelled
3615    /// first `message()` call whose retained initialization has since
3616    /// failed; call `message()` again to surface that error.
3617    #[must_use]
3618    pub fn error(&self) -> Option<&ConnectError> {
3619        match &self.recv {
3620            RecvState::Ready(s) => s.error(),
3621            RecvState::Failed(e) => Some(e),
3622            _ => None,
3623        }
3624    }
3625}
3626
3627impl<B, Req, RespView> BidiStream<B, Req, RespView> {
3628    /// Split the stream into independently owned send and receive halves,
3629    /// so the two sides can be driven from separate tasks (full duplex).
3630    ///
3631    /// Interleaved, response-dependent use — receiving an answer before
3632    /// sending the next message — requires an HTTP/2 transport, exactly as
3633    /// with an unsplit stream: on HTTP/1.1 no response arrives until the
3634    /// request body is complete, so a task waiting on the other half's
3635    /// progress deadlocks. Prefer moving each half into its own spawned
3636    /// task (as below) over storing them in named struct fields — the
3637    /// halves' full type parameters include the transport body type, which
3638    /// task-local inference names for you.
3639    ///
3640    /// The halves are plain moves of the stream's two sides — no locking is
3641    /// added — and there is no way to reassemble them. Semantics carried by
3642    /// each half:
3643    ///
3644    /// - Dropping the [`BidiSendHalf`] (or calling
3645    ///   [`close_send()`](BidiSendHalf::close_send)) ends the request body
3646    ///   cleanly; the RPC continues until the receive half finishes.
3647    /// - Dropping the [`BidiRecvHalf`] cancels the RPC — as when dropping a
3648    ///   whole `BidiStream` — after which sends on the other half fail.
3649    /// - When [`send()`](BidiSendHalf::send) fails because the server closed
3650    ///   the stream, the server's error is retrieved from the *receive* half
3651    ///   via [`message()`](BidiRecvHalf::message).
3652    ///
3653    /// # Example
3654    ///
3655    /// ```rust,ignore
3656    /// let (mut send, mut recv) = stream.into_split();
3657    /// let reader = tokio::spawn(async move {
3658    ///     while let Some(msg) = recv.message().await? {
3659    ///         println!("got: {msg:?}");
3660    ///     }
3661    ///     Ok::<_, connectrpc::ConnectError>(())
3662    /// });
3663    /// for req in requests {
3664    ///     send.send(req).await?;
3665    /// }
3666    /// send.close_send();
3667    /// reader.await.expect("reader task")?;
3668    /// ```
3669    #[must_use]
3670    pub fn into_split(self) -> (BidiSendHalf<Req>, BidiRecvHalf<B, RespView>) {
3671        (self.send, self.recv)
3672    }
3673}
3674
3675impl<B, Req, RespView> BidiStream<B, Req, RespView>
3676where
3677    B: Body<Data = Bytes> + Send + Unpin,
3678    B::Error: std::fmt::Display,
3679    Req: buffa::Message + crate::codec::JsonSerialize,
3680    RespView: MessageView<'static> + Send,
3681    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
3682{
3683    /// Send a request message.
3684    ///
3685    /// # Errors
3686    ///
3687    /// See [`BidiSendHalf::send`] for the error contract.
3688    pub async fn send(&mut self, msg: Req) -> Result<(), ConnectError> {
3689        self.send.send(msg).await
3690    }
3691
3692    /// Close the send side of the stream. Idempotent.
3693    /// See [`BidiSendHalf::close_send`].
3694    pub fn close_send(&mut self) {
3695        self.send.close_send();
3696    }
3697
3698    /// Receive the next response message.
3699    ///
3700    /// # Errors
3701    ///
3702    /// See [`BidiRecvHalf::message`] for the full contract.
3703    pub async fn message<M>(&mut self) -> Result<Option<crate::StreamMessage<M>>, ConnectError>
3704    where
3705        B: 'static,
3706        RespView: MessageView<'static, Owned = M> + 'static,
3707        M: HasMessageView<View<'static> = RespView>,
3708    {
3709        self.recv.message().await
3710    }
3711
3712    /// Response headers. See [`BidiRecvHalf::headers`].
3713    #[must_use]
3714    pub fn headers(&self) -> Option<&http::HeaderMap> {
3715        self.recv.headers()
3716    }
3717
3718    /// Trailing metadata. See [`BidiRecvHalf::trailers`].
3719    #[must_use]
3720    pub fn trailers(&self) -> Option<&http::HeaderMap> {
3721        self.recv.trailers()
3722    }
3723
3724    /// Terminal error that ended the stream, if any.
3725    /// See [`BidiRecvHalf::error`].
3726    #[must_use]
3727    pub fn error(&self) -> Option<&ConnectError> {
3728        self.recv.error()
3729    }
3730}
3731
3732/// Make a bidirectional-streaming RPC call.
3733///
3734/// Opens a stream to the server and returns a [`BidiStream`] handle for
3735/// sending request messages and receiving responses. No messages are sent
3736/// until the first [`BidiStream::send`] call.
3737///
3738/// The response future is stored and awaited lazily on the first
3739/// [`BidiStream::message`] call — this supports full-duplex servers that
3740/// wait for the first request message before sending response headers.
3741///
3742/// # Example
3743///
3744/// ```rust,ignore
3745/// let mut stream = call_bidi_stream::<_, MyReq, MyRespView>(
3746///     &transport, &config, "my.Service", "Method", CallOptions::default(),
3747/// ).await?;
3748/// stream.send(req).await?;
3749/// stream.close_send();
3750/// while let Some(msg) = stream.message().await? { /* ... */ }
3751/// ```
3752pub async fn call_bidi_stream<T, Req, RespView>(
3753    transport: &T,
3754    config: &ClientConfig,
3755    service: &str,
3756    method: &str,
3757    options: CallOptions,
3758) -> Result<BidiStream<T::ResponseBody, Req, RespView>, ConnectError>
3759where
3760    T: ClientTransport,
3761    <T::ResponseBody as Body>::Error: std::fmt::Display,
3762    Req: buffa::Message + crate::codec::JsonSerialize,
3763    RespView: MessageView<'static> + Send,
3764    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
3765{
3766    let options = effective_options(config, options);
3767
3768    // Build the full URI from base_uri and service/method path
3769    let base_str = config.base_uri.to_string();
3770    let base_str = base_str.trim_end_matches('/');
3771    let full_uri = format!("{base_str}/{service}/{method}");
3772    let uri: Uri = full_uri
3773        .parse()
3774        .map_err(|e| ConnectError::internal(format!("invalid URI: {e}")))?;
3775
3776    // Set up the channel-backed request body. Channel depth 32 matches
3777    // typical h2 stream window; sends beyond this backpressure naturally.
3778    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, ConnectError>>(32);
3779    let body: ClientBody = ChannelBody { rx }.boxed();
3780
3781    // Envelope encoder for send() — same compression setup as server-stream.
3782    let compression_for_encoder = config.request_compression.as_ref().map(|enc| {
3783        (
3784            std::sync::Arc::new(config.compression.clone()),
3785            enc.as_str(),
3786        )
3787    });
3788    let encoder = crate::envelope::EnvelopeEncoder::new(
3789        compression_for_encoder,
3790        config.compression_policy.with_override(options.compress),
3791    );
3792
3793    let deadline = client_deadline(options.timeout, config.protocol);
3794
3795    // Build the HTTP request with protocol-aware streaming headers
3796    let mut builder = Request::builder().method(http::Method::POST).uri(uri);
3797    builder = add_streaming_request_headers(builder, config, options.timeout);
3798
3799    let headers = builder.headers_mut().unwrap();
3800    for (name, value) in &options.headers {
3801        headers.append(name.clone(), value.clone());
3802    }
3803
3804    let http_request = builder
3805        .body(body)
3806        .map_err(|e| ConnectError::internal(format!("failed to build request: {e}")))?;
3807
3808    // Spawn the transport send so the request initiates immediately and
3809    // ChannelBody gets polled as sends happen, independent of when the
3810    // caller first calls message(). See RecvState doc for the deadlock
3811    // this avoids.
3812    //
3813    // Uses tokio::spawn directly (not spawn_detached) because
3814    // RecvState::AwaitingHeaders needs JoinHandle<Result<...>>. There is a
3815    // second such site: `message()` spawns the RecvState::Constructing task.
3816    // If wasm32+client becomes supported, factor both into a
3817    // spawn_with_result helper that bridges via oneshot on wasm.
3818    let response_fut = transport.send(http_request);
3819    let response_task = tokio::spawn(async move {
3820        response_fut
3821            .await
3822            .map_err(|e| map_transport_send_error(e, "request failed"))
3823    });
3824
3825    Ok(BidiStream {
3826        send: BidiSendHalf {
3827            tx: Some(tx),
3828            encoder,
3829            codec_format: config.codec_format,
3830            deadline,
3831            _req: PhantomData,
3832        },
3833        recv: BidiRecvHalf {
3834            recv: RecvState::AwaitingHeaders(response_task),
3835            stream_config: StreamConfig {
3836                protocol: config.protocol,
3837                codec_format: config.codec_format,
3838                compression: config.compression.clone(),
3839                max_message_size: options.max_message_size,
3840                deadline,
3841            },
3842        },
3843    })
3844}
3845
3846/// Make a client-streaming RPC call.
3847///
3848/// Sends multiple request messages as envelope-framed data and receives a single
3849/// envelope-framed response with END_STREAM. Returns a [`UnaryResponse`] containing
3850/// the decoded response message along with headers and trailers.
3851///
3852/// The request body IS the stream: each item yielded by `requests` is
3853/// encoded into an envelope frame as the transport asks for the next chunk
3854/// of body data. The transport begins sending as soon as the first message
3855/// is available, backpressure is the HTTP layer's own flow control, and
3856/// peak memory stays around one envelope rather than the full concatenated
3857/// body.
3858///
3859/// `requests` is an asynchronous [`Stream`], so messages can be produced as
3860/// they become available (paced by timers, read from sockets, forwarded from
3861/// channels) without buffering the whole request up front. The
3862/// [`ClientRequestStream`] bound additionally requires `Send + 'static`
3863/// because the stream backs the request body, which can outlive the call
3864/// frame and move across threads — yield owned messages (no borrows of
3865/// local data), or feed the call from a channel-backed stream. For a
3866/// collection that is already in hand, wrap it with [`stream_iter`]:
3867///
3868/// ```rust,ignore
3869/// let resp = call_client_stream(
3870///     &transport, &config, "svc", "Method",
3871///     connectrpc::stream_iter(vec![req1, req2]),
3872///     CallOptions::default(),
3873/// ).await?;
3874/// ```
3875///
3876/// Because the transport owns the polling of `requests`, upload liveness
3877/// follows HTTP semantics: a server that ends the RPC while `requests` is
3878/// still pending (for example, rejecting the call partway through the
3879/// upload) produces a response and the call returns without draining the
3880/// stream, while a server that merely sends response headers early and
3881/// keeps consuming the upload keeps receiving messages.
3882///
3883/// # Cancellation
3884///
3885/// Dropping the returned future (caller cancellation) or letting its deadline
3886/// expire drops the in-flight transport send — and with it the request body
3887/// and the caller's stream — even if the transport is still waiting for
3888/// response headers. As a consequence, a request that was still being sent
3889/// when the call was abandoned may never reach the server; a caller that
3890/// needs the request delivered must drive the call to completion.
3891///
3892/// # Errors
3893///
3894/// Returns an error if a request message cannot be encoded, the transport
3895/// fails, the whole-call deadline expires, the server responds with an
3896/// error, or the response cannot be decoded.
3897pub async fn call_client_stream<T, Req, RespView>(
3898    transport: &T,
3899    config: &ClientConfig,
3900    service: &str,
3901    method: &str,
3902    requests: impl ClientRequestStream<Req>,
3903    options: CallOptions,
3904) -> Result<UnaryResponse<OwnedView<RespView>>, ConnectError>
3905where
3906    T: ClientTransport,
3907    <T::ResponseBody as Body>::Error: std::fmt::Display,
3908    Req: buffa::Message + crate::codec::JsonSerialize,
3909    RespView: MessageView<'static> + Send,
3910    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
3911{
3912    let options = effective_options(config, options);
3913
3914    // Build the full URI from base_uri and service/method path
3915    let base_str = config.base_uri.to_string();
3916    let base_str = base_str.trim_end_matches('/');
3917    let full_uri = format!("{base_str}/{service}/{method}");
3918    let uri: Uri = full_uri
3919        .parse()
3920        .map_err(|e| ConnectError::internal(format!("invalid URI: {e}")))?;
3921
3922    let compression_for_encoder = config.request_compression.as_ref().map(|enc| {
3923        (
3924            std::sync::Arc::new(config.compression.clone()),
3925            enc.as_str(),
3926        )
3927    });
3928    let encoder = crate::envelope::EnvelopeEncoder::new(
3929        compression_for_encoder,
3930        config.compression_policy.with_override(options.compress),
3931    );
3932
3933    // The stream backs the request body directly: the transport polls it for
3934    // the next frame as it is able to send. An encode failure is reported
3935    // through the body (aborting the request) and stashed here so the call
3936    // can surface the precise error instead of a generic transport failure.
3937    let encode_error: std::sync::Arc<std::sync::Mutex<Option<ConnectError>>> =
3938        std::sync::Arc::default();
3939    let body: ClientBody = EncodingBody {
3940        stream: sync_wrapper::SyncWrapper::new(requests),
3941        encoder,
3942        codec_format: config.codec_format,
3943        error: encode_error.clone(),
3944        done: false,
3945    }
3946    .boxed();
3947
3948    // Compute deadline BEFORE sending, matching Go's ctx.Deadline() semantics
3949    let deadline = client_deadline(options.timeout, config.protocol);
3950
3951    // Build the HTTP request with protocol-aware streaming headers
3952    let mut builder = Request::builder().method(http::Method::POST).uri(uri);
3953    builder = add_streaming_request_headers(builder, config, options.timeout);
3954
3955    // Merge user-provided headers
3956    let headers = builder.headers_mut().unwrap();
3957    for (name, value) in &options.headers {
3958        headers.append(name.clone(), value.clone());
3959    }
3960
3961    let http_request = builder
3962        .body(body)
3963        .map_err(|e| ConnectError::internal(format!("failed to build request: {e}")))?;
3964
3965    // Enforce the client-side deadline on send + parse. The transport polls
3966    // the request body (and therefore the caller's stream) while this send
3967    // future — or, for connection-driver transports, their background task —
3968    // makes progress; there is no library-side pump that could hang on an
3969    // idle stream or cut off an upload the server is still consuming.
3970    // Abandonment (dropping the call future, or the deadline firing) drops
3971    // the send future — and with it the request — directly: there is no
3972    // detached task to outlive the call (#224).
3973    let result = with_deadline(deadline, async {
3974        let response = transport
3975            .send(http_request)
3976            .await
3977            .map_err(|e| map_transport_send_error(e, "request failed"))?;
3978
3979        // For gRPC, the response is envelope-framed like a unary gRPC response
3980        // (single data envelope + trailers). Reuse parse_grpc_unary_response.
3981        match config.protocol {
3982            Protocol::Grpc | Protocol::GrpcWeb => {
3983                parse_grpc_unary_response(response, config, &options, deadline).await
3984            }
3985            Protocol::Connect => {
3986                parse_connect_client_stream_response(response, config, &options, deadline).await
3987            }
3988        }
3989    })
3990    .await;
3991
3992    // An encode failure aborts the request at the transport level; surface
3993    // the precise encode error instead of the generic transport failure —
3994    // unconditionally, because a server's early response can race the abort
3995    // and produce an `Ok` result for a truncated, encode-aborted upload.
3996    //
3997    // The race runs the other way too, and that direction is left alone on
3998    // purpose: with the body driven in the background, an encode failure can
3999    // land after this check and is then never read, so the call reports the
4000    // server's `Ok`. That is the intended outcome — the server had already
4001    // produced a complete response, so the truncated tail did not affect it.
4002    if let Some(err) = encode_error
4003        .lock()
4004        .unwrap_or_else(std::sync::PoisonError::into_inner)
4005        .take()
4006    {
4007        return Err(err);
4008    }
4009    result
4010}
4011
4012/// Parse a Connect protocol client-streaming response.
4013async fn parse_connect_client_stream_response<B, RespView>(
4014    response: Response<B>,
4015    config: &ClientConfig,
4016    options: &CallOptions,
4017    deadline: Option<std::time::Instant>,
4018) -> Result<UnaryResponse<OwnedView<RespView>>, ConnectError>
4019where
4020    B: Body<Data = Bytes> + Send,
4021    B::Error: std::fmt::Display,
4022    RespView: MessageView<'static> + Send,
4023    RespView::Owned: buffa::Message + crate::codec::JsonDeserialize,
4024{
4025    let status = response.status();
4026
4027    if !status.is_success() {
4028        let response_headers = response.headers().clone();
4029
4030        let error_encoding = response_headers
4031            .get(http::header::CONTENT_ENCODING)
4032            .and_then(|v| v.to_str().ok())
4033            .map(|s| s.to_owned());
4034
4035        let max_err_size = options
4036            .max_message_size
4037            .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE);
4038
4039        // As in the server-stream path above: a failure reading the error body
4040        // is itself terminal, so it carries the response headers rather than
4041        // arriving bare. No trailers exist yet here.
4042        let body = collect_body_bounded(response.into_body(), max_err_size, deadline)
4043            .await
4044            .map_err(|mut e| {
4045                e.set_response_headers(response_headers.clone());
4046                e
4047            })?;
4048
4049        // Decompress if the server set Content-Encoding. On failure,
4050        // fall through to the generic HTTP-status error below.
4051        let body = match error_encoding {
4052            Some(encoding) => {
4053                match config
4054                    .compression
4055                    .decompress_with_limit(&encoding, body, max_err_size)
4056                {
4057                    Ok(decompressed) => Some(decompressed),
4058                    Err(e) => {
4059                        tracing::debug!(
4060                            "failed to decompress Connect error response ({encoding}): {e}"
4061                        );
4062                        None
4063                    }
4064                }
4065            }
4066            None => Some(body),
4067        };
4068
4069        if let Some(body) = body
4070            && let Ok(error) = serde_json::from_slice::<ConnectErrorResponse>(&body)
4071        {
4072            let code = error
4073                .code
4074                .as_deref()
4075                .and_then(|s| s.parse::<ErrorCode>().ok())
4076                .unwrap_or_else(|| http_status_to_error_code(status));
4077            let mut err = ConnectError::new(code, error.message.unwrap_or_default());
4078            err.details = error.details;
4079            err.set_response_headers(response_headers);
4080            return Err(err);
4081        }
4082
4083        let code = http_status_to_error_code(status);
4084        let mut err = ConnectError::new(code, format!("HTTP error {}", status.as_u16()));
4085        err.set_response_headers(response_headers);
4086        return Err(err);
4087    }
4088
4089    let encoding = response
4090        .headers()
4091        .get(config.protocol.content_encoding_header())
4092        .and_then(|v| v.to_str().ok())
4093        .map(|s| s.to_owned());
4094
4095    let resp_headers = response.headers().clone();
4096
4097    let max_msg_size = options
4098        .max_message_size
4099        .unwrap_or(crate::service::DEFAULT_MAX_MESSAGE_SIZE);
4100
4101    // The Connect client-stream response body holds a data envelope (header +
4102    // payload up to max_msg_size) followed by an END_STREAM envelope (header +
4103    // JSON trailers/error). Add slack so a max-sized message is not falsely
4104    // rejected by the whole-body cap.
4105    let body_limit = max_msg_size
4106        .saturating_add(2 * crate::envelope::HEADER_SIZE)
4107        .saturating_add(RESPONSE_BUFFER_TRAILER_SLACK);
4108    let body = collect_body_bounded(response.into_body(), body_limit, deadline)
4109        .await
4110        .map_err(with_response_metadata(
4111            &resp_headers,
4112            &http::HeaderMap::new(),
4113        ))?;
4114
4115    let (data, trailers) = parse_connect_client_stream_envelopes(
4116        body,
4117        &config.compression,
4118        encoding.as_deref(),
4119        max_msg_size,
4120        &resp_headers,
4121    )?;
4122    // The trailers are parsed by now, so a decode failure reports them too.
4123    let message = decode_response_view::<RespView>(data, config.codec_format)
4124        .map_err(with_response_metadata(&resp_headers, &trailers))?;
4125
4126    Ok(UnaryResponse {
4127        headers: resp_headers,
4128        body: message,
4129        trailers,
4130    })
4131}
4132
4133/// Scan a collected Connect client-streaming response body.
4134///
4135/// The body must contain exactly one data envelope followed by an END_STREAM
4136/// envelope, the protocol-level terminus: it supplies the trailers (or a
4137/// terminal Connect error) and marks the response complete. A body that
4138/// yields the data message and then ends before END_STREAM is truncated, not
4139/// successful, and is rejected with `internal` (matching the `ServerStream`
4140/// Connect EOF behavior and connect-go's classification of a missing
4141/// terminus as a wire-level error). Returns the (still encoded) message
4142/// payload and any
4143/// trailers carried in the END_STREAM metadata.
4144///
4145/// Scanning stops at END_STREAM, so anything after it is ignored rather than
4146/// decoded. A second data envelope is rejected before its payload is
4147/// decompressed, so the client never spends decompression work or memory on
4148/// more than the single message the RPC allows.
4149///
4150/// Every error out of here ends the RPC, so all of them carry the response
4151/// headers — attached here rather than inside the scan, so no failure mode
4152/// can omit them.
4153fn parse_connect_client_stream_envelopes(
4154    body: Bytes,
4155    compression: &crate::compression::CompressionRegistry,
4156    encoding: Option<&str>,
4157    max_msg_size: usize,
4158    resp_headers: &http::HeaderMap,
4159) -> Result<(Bytes, http::HeaderMap), ConnectError> {
4160    scan_connect_client_stream_envelopes(body, compression, encoding, max_msg_size).map_err(
4161        |mut err| {
4162            err.set_response_headers(resp_headers.clone());
4163            err
4164        },
4165    )
4166}
4167
4168/// The envelope scan behind [`parse_connect_client_stream_envelopes`].
4169/// Errors leave here without response headers; the caller attaches them.
4170fn scan_connect_client_stream_envelopes(
4171    body: Bytes,
4172    compression: &crate::compression::CompressionRegistry,
4173    encoding: Option<&str>,
4174    max_msg_size: usize,
4175) -> Result<(Bytes, http::HeaderMap), ConnectError> {
4176    let mut buf = BytesMut::from(body.as_ref());
4177    let mut message: Option<Bytes> = None;
4178    let mut trailers = http::HeaderMap::new();
4179    let mut saw_end_stream = false;
4180
4181    while !buf.is_empty() {
4182        let envelope = match Envelope::decode_with_limit(&mut buf, max_msg_size)? {
4183            Some(env) => env,
4184            None => break,
4185        };
4186
4187        if envelope.is_end_stream() {
4188            saw_end_stream = true;
4189            let end_stream_data = if envelope.is_compressed() {
4190                let enc = encoding.ok_or_else(|| {
4191                    ConnectError::internal("received compressed END_STREAM without encoding header")
4192                })?;
4193                compression
4194                    .decompress_with_limit(enc, envelope.data, max_msg_size)
4195                    .map_err(map_response_decompression_error)?
4196            } else {
4197                envelope.data
4198            };
4199
4200            let end_stream = parse_connect_end_stream(&end_stream_data)?;
4201
4202            if let Some(metadata) = end_stream.metadata {
4203                append_metadata_capped(&mut trailers, metadata);
4204            }
4205
4206            if let Some(err) = end_stream.error {
4207                let mut connect_error = end_stream_error_to_connect_error(err);
4208                // Same filter as the server-streaming shape, so identical
4209                // wire bytes give identical `ConnectError::trailers()`
4210                // whichever kind of call carried them. A gateway that
4211                // translates gRPC to Connect can put a `grpc-status` into
4212                // END_STREAM metadata, and it means the same thing there.
4213                connect_error.set_trailers(error_metadata_from_trailers(&trailers));
4214                return Err(connect_error);
4215            }
4216
4217            // END_STREAM is the end of the logical response stream; stop
4218            // scanning so trailing bytes after it are ignored.
4219            if !buf.is_empty() {
4220                tracing::debug!(
4221                    trailing_bytes = buf.len(),
4222                    "ignoring response data after the END_STREAM envelope"
4223                );
4224            }
4225            break;
4226        }
4227
4228        // Reject a second data message before doing any further work on it —
4229        // in particular before decompressing its payload.
4230        if message.is_some() {
4231            return Err(ConnectError::unimplemented(
4232                "client streaming response contains multiple data messages",
4233            ));
4234        }
4235
4236        let data = if envelope.is_compressed() {
4237            let enc = encoding.ok_or_else(|| {
4238                ConnectError::internal("received compressed message without encoding header")
4239            })?;
4240            compression
4241                .decompress_with_limit(enc, envelope.data, max_msg_size)
4242                .map_err(map_response_decompression_error)?
4243        } else {
4244            envelope.data
4245        };
4246
4247        if data.len() > max_msg_size {
4248            return Err(ConnectError::new(
4249                ErrorCode::ResourceExhausted,
4250                format!("message size {} exceeds limit {}", data.len(), max_msg_size),
4251            ));
4252        }
4253
4254        message = Some(data);
4255    }
4256
4257    let message = message.ok_or_else(|| {
4258        ConnectError::unimplemented("client streaming response contains no data messages")
4259    })?;
4260
4261    // The data message is present, but the body ended before END_STREAM. That
4262    // is a truncated response, not a completed one — match ServerStream's
4263    // Connect EOF handling rather than reporting success with no trailers.
4264    if !saw_end_stream {
4265        return Err(ConnectError::internal(
4266            "Connect streaming response ended without END_STREAM envelope",
4267        ));
4268    }
4269
4270    Ok((message, trailers))
4271}
4272
4273/// EndStreamResponse as received by the client.
4274#[derive(serde::Deserialize)]
4275struct ClientEndStreamResponse {
4276    error: Option<ClientEndStreamError>,
4277    metadata: Option<HashMap<String, Vec<String>>>,
4278}
4279
4280/// Error in the EndStreamResponse.
4281#[derive(serde::Deserialize)]
4282struct ClientEndStreamError {
4283    code: Option<String>,
4284    message: Option<String>,
4285    #[serde(default)]
4286    details: Vec<ErrorDetail>,
4287}
4288
4289/// Parse the body of a Connect END_STREAM envelope. A malformed body is a
4290/// wire-protocol violation, so it surfaces as `Internal` (matching connect-go)
4291/// rather than being silently treated as a clean close.
4292fn parse_connect_end_stream(data: &[u8]) -> Result<ClientEndStreamResponse, ConnectError> {
4293    serde_json::from_slice(data).map_err(|e| {
4294        ConnectError::internal(format!(
4295            "protocol error: malformed Connect END_STREAM JSON: {e}"
4296        ))
4297    })
4298}
4299
4300/// Convert the `error` member of a Connect END_STREAM body into the
4301/// caller-facing [`ConnectError`], with `Unknown` as the fallback code.
4302fn end_stream_error_to_connect_error(err: ClientEndStreamError) -> ConnectError {
4303    let mut connect_error = ConnectError::new(
4304        err.code
4305            .as_deref()
4306            .and_then(|c| c.parse().ok())
4307            .unwrap_or(ErrorCode::Unknown),
4308        err.message.unwrap_or_default(),
4309    );
4310    connect_error.details = err.details;
4311    connect_error
4312}
4313
4314/// Error response structure from ConnectRPC.
4315#[derive(serde::Deserialize)]
4316struct ConnectErrorResponse {
4317    #[serde(default)]
4318    code: Option<String>,
4319    #[serde(default)]
4320    message: Option<String>,
4321    #[serde(default)]
4322    details: Vec<ErrorDetail>,
4323}
4324
4325/// Maps an HTTP status code to a Connect error code per the Connect protocol spec.
4326///
4327/// Only specific HTTP status codes have defined mappings. All other codes map to
4328/// `Unknown` per the specification.
4329fn http_status_to_error_code(status: http::StatusCode) -> ErrorCode {
4330    match status.as_u16() {
4331        400 => ErrorCode::Internal,
4332        401 => ErrorCode::Unauthenticated,
4333        403 => ErrorCode::PermissionDenied,
4334        404 => ErrorCode::Unimplemented,
4335        408 => ErrorCode::DeadlineExceeded,
4336        429 => ErrorCode::Unavailable,
4337        502 => ErrorCode::Unavailable,
4338        503 => ErrorCode::Unavailable,
4339        504 => ErrorCode::Unavailable,
4340        _ => ErrorCode::Unknown,
4341    }
4342}
4343
4344// ============================================================================
4345// Protocol-aware client helpers
4346// ============================================================================
4347
4348/// Get the content type for a unary request.
4349fn unary_request_content_type(config: &ClientConfig) -> &'static str {
4350    match config.protocol {
4351        Protocol::Connect => config.codec_format.content_type(),
4352        Protocol::Grpc | Protocol::GrpcWeb => config
4353            .protocol
4354            .response_content_type(config.codec_format, false),
4355    }
4356}
4357
4358/// Get the content type for a streaming request.
4359fn streaming_request_content_type(config: &ClientConfig) -> &'static str {
4360    config
4361        .protocol
4362        .response_content_type(config.codec_format, true)
4363}
4364
4365/// Format a timeout value for the protocol's timeout header.
4366fn format_timeout(timeout: Duration, protocol: Protocol) -> String {
4367    encoded_timeout(timeout, protocol).header_value()
4368}
4369
4370/// Add protocol-specific headers to a request builder for unary RPCs.
4371///
4372/// `applied_content_encoding` is the encoding that was ACTUALLY applied to
4373/// the Connect unary body (or `None` if the body was sent uncompressed,
4374/// e.g. because the compression policy's size threshold was not met). For
4375/// gRPC/gRPC-Web this is ignored — `grpc-encoding` is a capability
4376/// declaration and the per-message envelope flag signals actual compression.
4377fn add_unary_request_headers(
4378    mut builder: http::request::Builder,
4379    config: &ClientConfig,
4380    timeout: Option<Duration>,
4381    applied_content_encoding: Option<&str>,
4382) -> http::request::Builder {
4383    builder = builder.header(
4384        http::header::CONTENT_TYPE,
4385        unary_request_content_type(config),
4386    );
4387
4388    match config.protocol {
4389        Protocol::Connect => {
4390            builder = builder.header(connect_header::PROTOCOL_VERSION, "1");
4391            // Connect unary uses standard content-encoding / accept-encoding.
4392            // Only set Content-Encoding if compression was actually applied.
4393            if let Some(encoding) = applied_content_encoding {
4394                builder = builder.header(http::header::CONTENT_ENCODING, encoding);
4395            }
4396            let accept = config.compression.accept_encoding_header();
4397            if !accept.is_empty() {
4398                builder = builder.header(http::header::ACCEPT_ENCODING, accept);
4399            }
4400        }
4401        Protocol::Grpc => {
4402            builder = builder.header("te", "trailers");
4403            if let Some(ref encoding) = config.request_compression {
4404                builder = builder.header("grpc-encoding", encoding.as_str());
4405            }
4406            let accept = config.compression.accept_encoding_header();
4407            if !accept.is_empty() {
4408                builder = builder.header("grpc-accept-encoding", accept);
4409            }
4410        }
4411        Protocol::GrpcWeb => {
4412            if let Some(ref encoding) = config.request_compression {
4413                builder = builder.header("grpc-encoding", encoding.as_str());
4414            }
4415            let accept = config.compression.accept_encoding_header();
4416            if !accept.is_empty() {
4417                builder = builder.header("grpc-accept-encoding", accept);
4418            }
4419        }
4420    }
4421
4422    if let Some(timeout) = timeout {
4423        builder = builder.header(
4424            config.protocol.timeout_header(),
4425            format_timeout(timeout, config.protocol),
4426        );
4427    }
4428
4429    builder
4430}
4431
4432/// Add protocol-specific headers to a request builder for streaming RPCs.
4433fn add_streaming_request_headers(
4434    mut builder: http::request::Builder,
4435    config: &ClientConfig,
4436    timeout: Option<Duration>,
4437) -> http::request::Builder {
4438    builder = builder.header(
4439        http::header::CONTENT_TYPE,
4440        streaming_request_content_type(config),
4441    );
4442
4443    match config.protocol {
4444        Protocol::Connect => {
4445            builder = builder.header(connect_header::PROTOCOL_VERSION, "1");
4446        }
4447        Protocol::Grpc => {
4448            builder = builder.header("te", "trailers");
4449        }
4450        Protocol::GrpcWeb => {}
4451    }
4452
4453    let encoding_header = config.protocol.content_encoding_header();
4454    let accept_header = config.protocol.accept_encoding_header();
4455
4456    if let Some(ref encoding) = config.request_compression {
4457        builder = builder.header(encoding_header, encoding.as_str());
4458    }
4459    let accept = config.compression.accept_encoding_header();
4460    if !accept.is_empty() {
4461        builder = builder.header(accept_header, accept);
4462    }
4463
4464    if let Some(timeout) = timeout {
4465        builder = builder.header(
4466            config.protocol.timeout_header(),
4467            format_timeout(timeout, config.protocol),
4468        );
4469    }
4470
4471    builder
4472}
4473
4474/// The trailers that carry the status itself rather than user metadata.
4475/// Their content reaches the caller as the error's `code`, `message` and
4476/// `details`, so repeating them as metadata would duplicate the status and
4477/// re-expose the raw `grpc-status-details-bin` bytes. The server side
4478/// writes these same three names via `hdr`.
4479fn is_status_trailer(name: &http::HeaderName) -> bool {
4480    *name == hdr::GRPC_STATUS || *name == hdr::GRPC_MESSAGE || *name == hdr::GRPC_STATUS_DETAILS_BIN
4481}
4482
4483/// The trailing metadata a terminal error should expose: the trailers that
4484/// arrived, minus the status-bearing ones. `ServerStream::trailers()` still
4485/// reports the wire trailers verbatim — the error's view and the stream's
4486/// differ deliberately, and every write of an error's trailers on the
4487/// response path goes through here so the two protocols agree.
4488fn error_metadata_from_trailers(trailers: &http::HeaderMap) -> http::HeaderMap {
4489    let mut out = http::HeaderMap::new();
4490    for (key, value) in trailers {
4491        if !is_status_trailer(key) {
4492            out.append(key, value.clone());
4493        }
4494    }
4495    out
4496}
4497
4498/// Parse a gRPC error from HTTP/2 trailers or gRPC-Web trailer frame headers.
4499fn parse_grpc_error_from_trailers(trailers: &http::HeaderMap) -> Option<ConnectError> {
4500    let raw = trailers.get("grpc-status")?;
4501    // A present-but-unparseable status is a protocol error, not an absent
4502    // status — it must not read as success. grpc-go maps malformed
4503    // grpc-status to Unknown.
4504    let Some(status) = raw.to_str().ok().and_then(|s| s.parse::<u32>().ok()) else {
4505        return Some(ConnectError::new(
4506            ErrorCode::Unknown,
4507            format!("protocol error: malformed grpc-status: {raw:?}"),
4508        ));
4509    };
4510
4511    if status == 0 {
4512        return None; // OK
4513    }
4514
4515    let code = ErrorCode::from_grpc_code(status).unwrap_or(ErrorCode::Unknown);
4516    let message = trailers
4517        .get("grpc-message")
4518        .and_then(|v| v.to_str().ok())
4519        .map(grpc_percent_decode);
4520
4521    let mut err = ConnectError::new(code, message.unwrap_or_default());
4522
4523    // Parse error details from grpc-status-details-bin
4524    if let Some(details_b64) = trailers
4525        .get("grpc-status-details-bin")
4526        .and_then(|v| v.to_str().ok())
4527    {
4528        use base64::Engine;
4529        if let Ok(details_bytes) = base64::engine::general_purpose::STANDARD
4530            .decode(details_b64)
4531            .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(details_b64))
4532        {
4533            err.details = crate::grpc_status::decode_details(&details_bytes);
4534        }
4535    }
4536
4537    err.set_trailers(error_metadata_from_trailers(trailers));
4538
4539    Some(err)
4540}
4541
4542/// Collect an HTTP response body into `Bytes`, enforcing a size limit.
4543///
4544/// `deadline` is the call's absolute deadline, used only to classify a
4545/// transport failure — see [`classify_body_read_error`]. Pass `None` for a
4546/// call without one; this function does not enforce the deadline, which
4547/// [`with_deadline`] does around the whole read.
4548///
4549/// Returns `ResourceExhausted` if the accumulated data exceeds `max_size`,
4550/// `DeadlineExceeded` if the body fails after the deadline has passed, and
4551/// `Internal` if it fails before.
4552async fn collect_body_bounded<B>(
4553    body: B,
4554    max_size: usize,
4555    deadline: Option<std::time::Instant>,
4556) -> Result<Bytes, ConnectError>
4557where
4558    B: Body<Data = Bytes>,
4559    B::Error: std::fmt::Display,
4560{
4561    let mut buf = BytesMut::new();
4562    let mut stream = std::pin::pin!(body);
4563    loop {
4564        match std::future::poll_fn(|cx| stream.as_mut().poll_frame(cx)).await {
4565            Some(Ok(frame)) => {
4566                // Trailer frames are skipped: Connect unary/error bodies don't
4567                // use HTTP trailers (those come via `trailer-` prefixed headers
4568                // or the JSON body).
4569                if let Ok(data) = frame.into_data() {
4570                    if buf.len().saturating_add(data.len()) > max_size {
4571                        return Err(ConnectError::new(
4572                            ErrorCode::ResourceExhausted,
4573                            format!("response body size exceeds limit {max_size}"),
4574                        ));
4575                    }
4576                    buf.extend_from_slice(&data);
4577                }
4578            }
4579            Some(Err(e)) => {
4580                return Err(classify_body_read_error(
4581                    "failed to read response body",
4582                    &e,
4583                    deadline,
4584                ));
4585            }
4586            None => break,
4587        }
4588    }
4589    Ok(buf.freeze())
4590}
4591
4592/// Percent-decode a gRPC message string.
4593///
4594/// Decode a gRPC percent-encoded message string back to UTF-8.
4595fn grpc_percent_decode(s: &str) -> String {
4596    percent_encoding::percent_decode_str(s)
4597        .decode_utf8_lossy()
4598        .into_owned()
4599}
4600
4601/// Parse a gRPC-Web trailer frame from response body data.
4602///
4603/// Parse a gRPC-Web trailer frame, optionally decompressing with the given registry.
4604fn parse_grpc_web_trailer_frame_with_compression(
4605    data: &[u8],
4606    decompression: Option<(&CompressionRegistry, &str)>,
4607) -> Option<http::HeaderMap> {
4608    if data.len() < 5 || data[0] & 0x80 == 0 {
4609        return None;
4610    }
4611    let is_compressed = data[0] & 0x01 != 0;
4612    let len = u32::from_be_bytes([data[1], data[2], data[3], data[4]]) as usize;
4613    // Cap trailer frame size to prevent a malicious server from forcing
4614    // unbounded memory allocation. 1 MB is generous for trailer metadata.
4615    const MAX_TRAILER_SIZE: usize = 1024 * 1024;
4616    if len > MAX_TRAILER_SIZE || data.len() < 5 + len {
4617        return None;
4618    }
4619    let raw_payload = &data[5..5 + len];
4620
4621    // Decompress if the compressed flag is set
4622    let payload_bytes;
4623    let payload = if is_compressed {
4624        if let Some((registry, encoding)) = decompression {
4625            payload_bytes = registry
4626                .decompress_with_limit(
4627                    encoding,
4628                    Bytes::copy_from_slice(raw_payload),
4629                    MAX_TRAILER_SIZE,
4630                )
4631                .ok()?;
4632            std::str::from_utf8(&payload_bytes).ok()?
4633        } else {
4634            return None;
4635        }
4636    } else {
4637        std::str::from_utf8(raw_payload).ok()?
4638    };
4639
4640    let mut headers = http::HeaderMap::new();
4641    // Split on \r\n or \n to handle both formats
4642    for line in payload.split('\n') {
4643        let line = line.trim_end_matches('\r');
4644        if line.is_empty() {
4645            continue;
4646        }
4647        // Support both "key: value" and "key:value" formats
4648        if let Some((key, value)) = line.split_once(':')
4649            && let (Ok(name), Ok(val)) = (
4650                http::header::HeaderName::from_bytes(key.trim().as_bytes()),
4651                http::HeaderValue::from_str(value.trim()),
4652            )
4653        {
4654            // Use the fallible `try_append`: `HeaderMap` panics in
4655            // `append` once the number of stored entries would exceed its
4656            // hard ceiling (`MAX_SIZE = 1 << 15`). A hostile server can pack
4657            // tens of thousands of short trailer lines into a payload that
4658            // stays under `MAX_TRAILER_SIZE` (bytes, not entries), so the
4659            // byte cap alone does not prevent the panic. Stop accumulating at
4660            // the ceiling rather than crashing the RPC task.
4661            if headers.try_append(name, val).is_err() {
4662                break;
4663            }
4664        }
4665    }
4666    Some(headers)
4667}
4668
4669/// Append Connect end-stream `metadata` into `trailers`, capping at the
4670/// `HeaderMap` entry ceiling.
4671///
4672/// `metadata` is deserialized from a server-supplied JSON end-stream frame, so
4673/// its size is attacker-controlled. `HeaderMap::append` panics once the number
4674/// of stored entries would exceed its hard ceiling (`MAX_SIZE = 1 << 15`); a
4675/// hostile server could send tens of thousands of distinct keys in a few
4676/// hundred KB of JSON and crash the RPC task. Use the fallible `try_append`
4677/// and stop at the ceiling instead.
4678fn append_metadata_capped(trailers: &mut http::HeaderMap, metadata: HashMap<String, Vec<String>>) {
4679    'outer: for (name, values) in metadata {
4680        for value in values {
4681            if let (Ok(name), Ok(value)) = (
4682                http::header::HeaderName::from_bytes(name.as_bytes()),
4683                http::header::HeaderValue::from_str(&value),
4684            ) && trailers.try_append(name, value).is_err()
4685            {
4686                break 'outer;
4687            }
4688        }
4689    }
4690}
4691
4692#[cfg(test)]
4693mod tests {
4694    use super::*;
4695
4696    #[test]
4697    fn overflow_payload_is_internal_at_response_decode() {
4698        use buffa_types::google::protobuf::__buffa::view::StringValueView;
4699
4700        // The pathological payload never reaches `into_owned` — the client's
4701        // response decode boundary rejects it with the same classification
4702        // the deleted fallible `into_owned` used, so the wire-visible
4703        // behavior for an over-limit response is pinned here.
4704        let body = crate::request::tests::unknown_field_overflow_body();
4705        let err =
4706            decode_response_view::<StringValueView<'static>>(body, CodecFormat::Proto).unwrap_err();
4707        assert_eq!(err.code, ErrorCode::Internal);
4708    }
4709
4710    #[test]
4711    fn into_owned_parts_preserves_metadata() {
4712        use buffa::Message;
4713        use buffa::view::OwnedView;
4714        use buffa_types::google::protobuf::__buffa::view::StringValueView;
4715        use buffa_types::google::protobuf::StringValue;
4716
4717        let bytes = Bytes::from(StringValue::from("with-metadata").encode_to_vec());
4718        let view: OwnedView<StringValueView<'static>> = OwnedView::decode(bytes).unwrap();
4719        let mut headers = http::HeaderMap::new();
4720        headers.insert("x-probe", http::HeaderValue::from_static("h"));
4721        let mut trailers = http::HeaderMap::new();
4722        trailers.insert("x-trailer", http::HeaderValue::from_static("t"));
4723        let resp = UnaryResponse {
4724            headers,
4725            body: view,
4726            trailers,
4727        };
4728
4729        let (headers, owned, trailers) = resp.into_owned_parts();
4730        assert_eq!(owned.value, "with-metadata");
4731        assert_eq!(headers.get("x-probe").unwrap(), "h");
4732        assert_eq!(trailers.get("x-trailer").unwrap(), "t");
4733    }
4734
4735    #[cfg(feature = "json")]
4736    #[test]
4737    fn test_client_config() {
4738        let config = ClientConfig::new("http://localhost:8080".parse().unwrap())
4739            .json()
4740            .compress_requests("gzip");
4741
4742        assert_eq!(config.codec_format, CodecFormat::Json);
4743        assert_eq!(config.request_compression, Some("gzip".to_string()));
4744    }
4745
4746    #[cfg(not(feature = "json"))]
4747    #[test]
4748    fn test_client_config_proto_only() {
4749        // The `.json()` shorthand is removed in a proto-only build; the default
4750        // codec is proto and the rest of the builder is unaffected.
4751        let config =
4752            ClientConfig::new("http://localhost:8080".parse().unwrap()).compress_requests("gzip");
4753
4754        assert_eq!(config.codec_format, CodecFormat::Proto);
4755        assert_eq!(config.request_compression, Some("gzip".to_string()));
4756    }
4757
4758    #[cfg(feature = "client")]
4759    #[tokio::test]
4760    async fn http_client_connect_timeout_bounds_tcp_connect() {
4761        use std::time::Instant;
4762
4763        // RFC 5737 TEST-NET-1: reserved for documentation. Most hosts drop
4764        // SYNs to it (so an unbounded connect stalls on kernel retransmits,
4765        // ~130s on Linux defaults), but RFC 5737 doesn't mandate that — some
4766        // CI hosts actively reject. The assertion that matters is the upper
4767        // bound: a 100ms timeout must abort well before the kernel retry floor.
4768        let target = "http://192.0.2.1:9/";
4769        let timeout = Duration::from_millis(100);
4770
4771        let http = HttpClient::builder().connect_timeout(timeout).plaintext();
4772        let req = http::Request::builder()
4773            .method(http::Method::POST)
4774            .uri(target)
4775            .body(full_body(Bytes::new()))
4776            .unwrap();
4777
4778        let start = Instant::now();
4779        // Outer timeout guards a transparent-proxy host that accepts the
4780        // connect (so the bound under test never fires) and then never answers
4781        // the HTTP/1.1 request — without this the test would hang.
4782        let result = tokio::time::timeout(Duration::from_secs(3), http.send(req)).await;
4783        let elapsed = start.elapsed();
4784        let Ok(Err(err)) = result else {
4785            eprintln!("skipping: TEST-NET-1 reachable on this host (proxy?) in {elapsed:?}");
4786            return;
4787        };
4788
4789        // Generous slack for CI scheduling jitter — but well under the
4790        // multi-second kernel SYN-retry floor we'd hit without the bound.
4791        assert!(
4792            elapsed < Duration::from_secs(2),
4793            "connect_timeout(100ms) should abort within ~2s, took {elapsed:?}: {err}"
4794        );
4795    }
4796
4797    #[cfg(feature = "client")]
4798    #[tokio::test]
4799    async fn http_client_establishment_timeout_bounds_plaintext_connector() {
4800        use std::time::Instant;
4801
4802        // The establishment_timeout wrapper bounds the whole connector. For
4803        // plaintext that's just the TCP connect, so an unroutable TEST-NET-1
4804        // address must fail fast rather than stall on kernel SYN retransmits.
4805        let target = "http://192.0.2.1:9/";
4806        let http = HttpClient::builder()
4807            .establishment_timeout(Duration::from_millis(100))
4808            .plaintext();
4809        let req = http::Request::builder()
4810            .method(http::Method::POST)
4811            .uri(target)
4812            .body(full_body(Bytes::new()))
4813            .unwrap();
4814
4815        let start = Instant::now();
4816        // Outer timeout guards a transparent-proxy host that accepts the
4817        // connect (so the bound under test never fires) and then never answers
4818        // the HTTP/1.1 request — without this the test would hang.
4819        let result = tokio::time::timeout(Duration::from_secs(3), http.send(req)).await;
4820        let elapsed = start.elapsed();
4821        let Ok(Err(err)) = result else {
4822            eprintln!("skipping: TEST-NET-1 reachable on this host (proxy?) in {elapsed:?}");
4823            return;
4824        };
4825
4826        assert!(
4827            elapsed < Duration::from_secs(2),
4828            "establishment_timeout(100ms) should abort within ~2s, took {elapsed:?}: {err}"
4829        );
4830    }
4831
4832    #[cfg(feature = "client-tls")]
4833    #[tokio::test]
4834    async fn http_client_establishment_timeout_bounds_stalled_tls() {
4835        use std::time::Instant;
4836
4837        // A listener that accepts the TCP connection but never performs the TLS
4838        // handshake. The TCP connect succeeds, so only establishment_timeout (which
4839        // covers TCP + TLS for the connector) can release the stalled connect.
4840        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4841        let addr = listener.local_addr().unwrap();
4842        let server = tokio::spawn(async move {
4843            let mut held = Vec::new();
4844            while let Ok((stream, _)) = listener.accept().await {
4845                held.push(stream);
4846            }
4847        });
4848
4849        let tls_config = std::sync::Arc::new(
4850            rustls::ClientConfig::builder()
4851                .with_root_certificates(rustls::RootCertStore::empty())
4852                .with_no_client_auth(),
4853        );
4854        let http = HttpClient::builder()
4855            .establishment_timeout(Duration::from_millis(150))
4856            .with_tls(tls_config);
4857        let req = http::Request::builder()
4858            .method(http::Method::POST)
4859            .uri(format!("https://{addr}/"))
4860            .body(full_body(Bytes::new()))
4861            .unwrap();
4862
4863        let start = Instant::now();
4864        let err = http.send(req).await.expect_err("stalled TLS must fail");
4865        let elapsed = start.elapsed();
4866
4867        assert!(
4868            elapsed < Duration::from_secs(2),
4869            "establishment_timeout(150ms) should fire within ~2s, took {elapsed:?}: {err}"
4870        );
4871
4872        server.abort();
4873    }
4874
4875    #[cfg(feature = "client")]
4876    #[tokio::test]
4877    async fn http_client_plaintext_send_failure_preserves_source() {
4878        let http = HttpClient::plaintext();
4879        // Port 1 should not have a listener — the OS refuses immediately.
4880        let req = http::Request::builder()
4881            .method(http::Method::POST)
4882            .uri("http://127.0.0.1:1/")
4883            .body(full_body(Bytes::new()))
4884            .unwrap();
4885
4886        let err = http
4887            .send(req)
4888            .await
4889            .expect_err("connect to port 1 must fail");
4890        assert!(
4891            err.message
4892                .as_deref()
4893                .unwrap()
4894                .contains("HTTP request failed"),
4895            "unexpected message: {err:?}"
4896        );
4897        assert!(
4898            std::error::Error::source(&err).is_some(),
4899            "connection-refused failure must retain its cause as source(): {err:?}"
4900        );
4901    }
4902
4903    #[cfg(feature = "client-tls")]
4904    #[tokio::test]
4905    async fn http_client_tls_send_failure_preserves_source() {
4906        let tls_config = std::sync::Arc::new(
4907            rustls::ClientConfig::builder()
4908                .with_root_certificates(rustls::RootCertStore::empty())
4909                .with_no_client_auth(),
4910        );
4911        let http = HttpClient::with_tls(tls_config);
4912        // Port 1 should not have a listener — the OS refuses before any TLS
4913        // handshake starts.
4914        let req = http::Request::builder()
4915            .method(http::Method::POST)
4916            .uri("https://127.0.0.1:1/")
4917            .body(full_body(Bytes::new()))
4918            .unwrap();
4919
4920        let err = http
4921            .send(req)
4922            .await
4923            .expect_err("connect to port 1 must fail");
4924        assert!(
4925            err.message
4926                .as_deref()
4927                .unwrap()
4928                .contains("HTTPS request failed"),
4929            "unexpected message: {err:?}"
4930        );
4931        assert!(
4932            std::error::Error::source(&err).is_some(),
4933            "connection-refused failure must retain its cause as source(): {err:?}"
4934        );
4935    }
4936
4937    #[test]
4938    fn client_config_builders_round_trip_through_accessors() {
4939        // Each `with_*` builder must be readable back through the bare-name
4940        // accessor — the public read contract that replaces direct field
4941        // access (#90).
4942        let mut headers = http::HeaderMap::new();
4943        headers.insert("x-base", "v".parse().unwrap());
4944
4945        let config = ClientConfig::new("http://example.com:8080".parse().unwrap())
4946            .with_protocol(Protocol::Grpc)
4947            .with_codec_format(CodecFormat::Json)
4948            .with_compression(CompressionRegistry::default())
4949            .compress_requests("gzip")
4950            .with_compression_policy(CompressionPolicy::default())
4951            .with_default_timeout(Duration::from_secs(30))
4952            .with_default_max_message_size(4096)
4953            .with_default_headers(headers.clone())
4954            .with_default_header("x-extra", "1");
4955
4956        assert_eq!(config.base_uri().to_string(), "http://example.com:8080/");
4957        assert_eq!(config.protocol(), Protocol::Grpc);
4958        assert_eq!(config.codec_format(), CodecFormat::Json);
4959        assert_eq!(config.request_compression(), Some("gzip"));
4960        assert_eq!(config.default_timeout(), Some(Duration::from_secs(30)));
4961        assert_eq!(config.default_max_message_size(), Some(4096));
4962        assert_eq!(config.default_headers().get("x-base").unwrap(), "v");
4963        assert_eq!(config.default_headers().get("x-extra").unwrap(), "1");
4964        // `compression()` and `compression_policy()` return references / copies
4965        // of the registry/policy; they don't impl PartialEq, so we just exercise
4966        // that the accessors compile and don't panic.
4967        let _ = config.compression();
4968        let _ = config.compression_policy();
4969    }
4970
4971    #[test]
4972    fn client_config_defaults() {
4973        let config = ClientConfig::new("http://localhost".parse().unwrap());
4974        assert_eq!(config.protocol(), Protocol::Connect);
4975        assert_eq!(config.codec_format(), CodecFormat::Proto);
4976        assert_eq!(config.request_compression(), None);
4977        assert_eq!(config.default_timeout(), None);
4978        assert_eq!(config.default_max_message_size(), None);
4979        assert!(config.default_headers().is_empty());
4980    }
4981
4982    #[test]
4983    fn call_options_builders_round_trip_through_accessors() {
4984        let options = CallOptions::default()
4985            .with_timeout(Duration::from_secs(5))
4986            .with_header("x-request-id", "abc")
4987            .with_max_message_size(2048)
4988            .with_compress(true);
4989
4990        assert_eq!(options.timeout(), Some(Duration::from_secs(5)));
4991        assert_eq!(options.headers().get("x-request-id").unwrap(), "abc");
4992        assert_eq!(options.max_message_size(), Some(2048));
4993        assert_eq!(options.compress(), Some(true));
4994    }
4995
4996    #[test]
4997    fn call_options_defaults() {
4998        let options = CallOptions::default();
4999        assert!(options.headers().is_empty());
5000        assert_eq!(options.timeout(), None);
5001        assert_eq!(options.max_message_size(), None);
5002        assert_eq!(options.compress(), None);
5003    }
5004
5005    #[cfg(feature = "client")]
5006    #[test]
5007    fn test_http_client_plaintext_creation() {
5008        let _client = HttpClient::plaintext();
5009        let _client = HttpClient::plaintext_http2_only();
5010    }
5011
5012    /// Compile-time assertion that public client types all satisfy `Debug`.
5013    ///
5014    /// `Result::unwrap_err()` requires `T: Debug` (so that an unexpected `Ok`
5015    /// can be printed in the panic message). Without these impls, integration
5016    /// tests doing `client.foo(req).await.unwrap_err()` won't compile.
5017    #[test]
5018    fn client_types_are_debug() {
5019        fn assert_debug<T: std::fmt::Debug>() {}
5020
5021        // UnaryResponse<Resp> — derived; the bound `Resp: Debug` is satisfied
5022        // by `OwnedView<V>` whenever `V: Debug` (which all generated view
5023        // types are).
5024        assert_debug::<UnaryResponse<()>>();
5025
5026        // Stream types — manual impls that print state summary (body type `B`
5027        // is typically `hyper::body::Incoming` which isn't Debug).
5028        assert_debug::<ServerStream<http_body_util::Empty<Bytes>, ()>>();
5029        assert_debug::<BidiStream<http_body_util::Empty<Bytes>, (), ()>>();
5030        assert_debug::<BidiSendHalf<()>>();
5031        assert_debug::<BidiRecvHalf<http_body_util::Empty<Bytes>, ()>>();
5032
5033        // Transports — manual impls that print mode/connection state.
5034        #[cfg(feature = "client")]
5035        assert_debug::<HttpClient>();
5036    }
5037
5038    #[test]
5039    fn bidi_stream_auto_traits() {
5040        fn assert_send<T: Send>() {}
5041        fn assert_sync<T: Sync>() {}
5042        fn assert_unpin<T: Unpin>() {}
5043
5044        type TestBidi = BidiStream<http_body_util::Empty<Bytes>, (), ()>;
5045
5046        assert_send::<TestBidi>();
5047        assert_sync::<TestBidi>();
5048        assert_unpin::<TestBidi>();
5049
5050        // The halves are moved into separate spawned tasks, so their auto
5051        // traits are individually load-bearing, not just via containment.
5052        type TestSend = BidiSendHalf<()>;
5053        type TestRecv = BidiRecvHalf<http_body_util::Empty<Bytes>, ()>;
5054
5055        assert_send::<TestSend>();
5056        assert_sync::<TestSend>();
5057        assert_unpin::<TestSend>();
5058        assert_send::<TestRecv>();
5059        assert_sync::<TestRecv>();
5060        assert_unpin::<TestRecv>();
5061    }
5062
5063    fn connect_success_body(message: &str) -> Bytes {
5064        use buffa::Message;
5065        use buffa_types::google::protobuf::StringValue;
5066
5067        let mut body = BytesMut::new();
5068        body.extend_from_slice(
5069            &Envelope::data(StringValue::from(message).encode_to_bytes()).encode(),
5070        );
5071        body.extend_from_slice(&Envelope::end_stream(Bytes::from_static(b"{}")).encode());
5072        body.freeze()
5073    }
5074
5075    fn connect_response<B>(status: http::StatusCode, body: B) -> Response<B> {
5076        Response::builder().status(status).body(body).unwrap()
5077    }
5078
5079    fn bidi_stream_with_response_task<B>(
5080        response_task: tokio::task::JoinHandle<Result<Response<B>, ConnectError>>,
5081        deadline: Option<std::time::Instant>,
5082    ) -> BidiStream<
5083        B,
5084        buffa_types::google::protobuf::StringValue,
5085        buffa_types::google::protobuf::__buffa::view::StringValueView<'static>,
5086    > {
5087        BidiStream {
5088            send: BidiSendHalf {
5089                tx: None,
5090                encoder: crate::envelope::EnvelopeEncoder::uncompressed(),
5091                codec_format: CodecFormat::Proto,
5092                deadline,
5093                _req: PhantomData,
5094            },
5095            recv: BidiRecvHalf {
5096                recv: RecvState::AwaitingHeaders(response_task),
5097                stream_config: StreamConfig {
5098                    protocol: Protocol::Connect,
5099                    codec_format: CodecFormat::Proto,
5100                    compression: CompressionRegistry::new(),
5101                    max_message_size: Some(1024),
5102                    deadline,
5103                },
5104            },
5105        }
5106    }
5107
5108    struct GatedBody {
5109        shared: std::sync::Arc<std::sync::Mutex<GatedBodyState>>,
5110    }
5111
5112    struct GatedBodyRelease {
5113        shared: std::sync::Arc<std::sync::Mutex<GatedBodyState>>,
5114    }
5115
5116    struct GatedBodyState {
5117        first_poll: Option<tokio::sync::oneshot::Sender<()>>,
5118        released: Option<Bytes>,
5119        done: bool,
5120        waker: Option<std::task::Waker>,
5121    }
5122
5123    impl GatedBody {
5124        fn new() -> (Self, tokio::sync::oneshot::Receiver<()>, GatedBodyRelease) {
5125            let (first_poll_tx, first_poll_rx) = tokio::sync::oneshot::channel();
5126            let shared = std::sync::Arc::new(std::sync::Mutex::new(GatedBodyState {
5127                first_poll: Some(first_poll_tx),
5128                released: None,
5129                done: false,
5130                waker: None,
5131            }));
5132
5133            (
5134                Self {
5135                    shared: shared.clone(),
5136                },
5137                first_poll_rx,
5138                GatedBodyRelease { shared },
5139            )
5140        }
5141    }
5142
5143    impl GatedBodyRelease {
5144        fn release(self, bytes: Bytes) {
5145            let mut state = self.shared.lock().unwrap();
5146            state.released = Some(bytes);
5147            if let Some(waker) = state.waker.take() {
5148                waker.wake();
5149            }
5150        }
5151    }
5152
5153    impl Body for GatedBody {
5154        type Data = Bytes;
5155        type Error = ConnectError;
5156
5157        fn poll_frame(
5158            self: Pin<&mut Self>,
5159            cx: &mut std::task::Context<'_>,
5160        ) -> std::task::Poll<Option<Result<http_body::Frame<Bytes>, ConnectError>>> {
5161            let mut state = self.shared.lock().unwrap();
5162            if let Some(first_poll) = state.first_poll.take() {
5163                let _ = first_poll.send(());
5164            }
5165
5166            if state.done {
5167                return std::task::Poll::Ready(None);
5168            }
5169
5170            if let Some(bytes) = state.released.take() {
5171                state.done = true;
5172                return std::task::Poll::Ready(Some(Ok(http_body::Frame::data(bytes))));
5173            }
5174
5175            state.waker = Some(cx.waker().clone());
5176            std::task::Poll::Pending
5177        }
5178    }
5179
5180    #[tokio::test]
5181    async fn bidi_message_cancel_before_headers_resumes() {
5182        use buffa_types::google::protobuf::StringValue;
5183
5184        let (response_tx, response_rx) =
5185            tokio::sync::oneshot::channel::<Result<Response<Full<Bytes>>, ConnectError>>();
5186        let response_task = tokio::spawn(async move {
5187            response_rx
5188                .await
5189                .expect("test response sender should stay alive")
5190        });
5191        let mut stream = bidi_stream_with_response_task(response_task, None);
5192
5193        let mut first_message = Box::pin(stream.message::<StringValue>());
5194        assert!(matches!(
5195            futures::poll!(&mut first_message),
5196            std::task::Poll::Pending
5197        ));
5198        drop(first_message);
5199
5200        assert!(stream.error().is_none());
5201        assert!(stream.headers().is_none());
5202
5203        response_tx
5204            .send(Ok(connect_response(
5205                http::StatusCode::OK,
5206                Full::new(connect_success_body("hello")),
5207            )))
5208            .expect("detached response task should still receive headers");
5209
5210        let msg = stream
5211            .message::<StringValue>()
5212            .await
5213            .expect("cancelled header wait should resume")
5214            .expect("stream should yield first response message");
5215        assert_eq!(msg.view().value, "hello");
5216        assert!(stream.message::<StringValue>().await.unwrap().is_none());
5217    }
5218
5219    #[tokio::test]
5220    async fn bidi_message_cancel_during_connect_error_body_resumes() {
5221        use buffa_types::google::protobuf::StringValue;
5222
5223        let (body, body_polled, body_release) = GatedBody::new();
5224        let (response_ready_tx, response_ready_rx) = tokio::sync::oneshot::channel();
5225        let response_task = tokio::spawn(async move {
5226            let response = connect_response(http::StatusCode::BAD_REQUEST, body);
5227            let _ = response_ready_tx.send(());
5228            Ok(response)
5229        });
5230        response_ready_rx
5231            .await
5232            .expect("response task should have prepared headers");
5233        let mut stream = bidi_stream_with_response_task(response_task, None);
5234
5235        let mut first_message = Box::pin(stream.message::<StringValue>());
5236        assert!(matches!(
5237            futures::poll!(&mut first_message),
5238            std::task::Poll::Pending
5239        ));
5240        body_polled
5241            .await
5242            .expect("Connect error body should be polled");
5243        drop(first_message);
5244
5245        assert!(stream.error().is_none());
5246        assert!(stream.headers().is_none());
5247
5248        body_release.release(Bytes::from_static(
5249            br#"{"code":"invalid_argument","message":"bad request"}"#,
5250        ));
5251
5252        let err = stream
5253            .message::<StringValue>()
5254            .await
5255            .expect_err("server Connect error should be preserved");
5256        assert_eq!(err.code, ErrorCode::InvalidArgument);
5257        assert_eq!(err.message.as_deref(), Some("bad request"));
5258        let again = stream
5259            .message::<StringValue>()
5260            .await
5261            .expect_err("initialization error should be sticky");
5262        assert_eq!(again.code, ErrorCode::InvalidArgument);
5263        assert_eq!(stream.error().unwrap().code, ErrorCode::InvalidArgument);
5264    }
5265
5266    #[tokio::test(start_paused = true)]
5267    async fn bidi_message_deadline_before_headers_stays_sticky() {
5268        use buffa_types::google::protobuf::StringValue;
5269
5270        let (_response_tx, response_rx) =
5271            tokio::sync::oneshot::channel::<Result<Response<Full<Bytes>>, ConnectError>>();
5272        let response_task = tokio::spawn(async move {
5273            response_rx
5274                .await
5275                .expect("test intentionally keeps response pending")
5276        });
5277        let deadline = std::time::Instant::now() + Duration::from_millis(100);
5278        let mut stream = bidi_stream_with_response_task(response_task, Some(deadline));
5279
5280        let err = stream
5281            .message::<StringValue>()
5282            .await
5283            .expect_err("deadline should fail receive initialization");
5284        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
5285
5286        let again = stream
5287            .message::<StringValue>()
5288            .await
5289            .expect_err("deadline failure should be sticky");
5290        assert_eq!(again.code, ErrorCode::DeadlineExceeded);
5291        assert_eq!(stream.error().unwrap().code, ErrorCode::DeadlineExceeded);
5292    }
5293
5294    #[tokio::test(start_paused = true)]
5295    async fn bidi_message_deadline_during_connect_error_body_stays_sticky() {
5296        use buffa_types::google::protobuf::StringValue;
5297
5298        let (body, body_polled, _body_release) = GatedBody::new();
5299        let (response_ready_tx, response_ready_rx) = tokio::sync::oneshot::channel();
5300        let response_task = tokio::spawn(async move {
5301            let response = connect_response(http::StatusCode::BAD_REQUEST, body);
5302            let _ = response_ready_tx.send(());
5303            Ok(response)
5304        });
5305        response_ready_rx
5306            .await
5307            .expect("response task should have prepared headers");
5308
5309        let deadline = std::time::Instant::now() + Duration::from_millis(100);
5310        let mut stream = bidi_stream_with_response_task(response_task, Some(deadline));
5311
5312        let mut first_message = Box::pin(stream.message::<StringValue>());
5313        assert!(matches!(
5314            futures::poll!(&mut first_message),
5315            std::task::Poll::Pending
5316        ));
5317        body_polled
5318            .await
5319            .expect("Connect error body should be polled");
5320
5321        // Keep `_body_release` alive and unused so the gated body remains
5322        // pending until the call deadline fires.
5323        let err = first_message
5324            .await
5325            .expect_err("deadline should fail response construction");
5326        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
5327
5328        let again = stream
5329            .message::<StringValue>()
5330            .await
5331            .expect_err("construction deadline should be sticky");
5332        assert_eq!(again.code, ErrorCode::DeadlineExceeded);
5333        assert_eq!(stream.error().unwrap().code, ErrorCode::DeadlineExceeded);
5334    }
5335
5336    #[tokio::test]
5337    async fn bidi_message_transport_failure_is_sticky() {
5338        use buffa_types::google::protobuf::StringValue;
5339
5340        let response_task = tokio::spawn(async {
5341            Err::<Response<Full<Bytes>>, _>(ConnectError::unavailable("request failed: boom"))
5342        });
5343        let mut stream = bidi_stream_with_response_task(response_task, None);
5344
5345        let err = stream
5346            .message::<StringValue>()
5347            .await
5348            .expect_err("transport failure should fail receive initialization");
5349        assert_eq!(err.code, ErrorCode::Unavailable);
5350        assert_eq!(err.message.as_deref(), Some("request failed: boom"));
5351
5352        let again = stream
5353            .message::<StringValue>()
5354            .await
5355            .expect_err("transport failure should be sticky");
5356        assert_eq!(again.code, ErrorCode::Unavailable);
5357        assert_eq!(again.message.as_deref(), Some("request failed: boom"));
5358        assert_eq!(stream.error().unwrap().code, ErrorCode::Unavailable);
5359    }
5360
5361    #[tokio::test]
5362    async fn bidi_drop_aborts_headers_task() {
5363        let (guard_tx, guard_rx) = tokio::sync::oneshot::channel::<()>();
5364        let (_never_tx, never_rx) =
5365            tokio::sync::oneshot::channel::<Result<Response<Full<Bytes>>, ConnectError>>();
5366        let response_task = tokio::spawn(async move {
5367            let _guard = guard_tx;
5368            never_rx.await.expect("test never resolves the response")
5369        });
5370        let stream = bidi_stream_with_response_task(response_task, None);
5371        drop(stream);
5372
5373        // The abort drops the task and with it `_guard`, erroring the
5374        // receiver. Without the abort the task stays parked and this times out.
5375        tokio::time::timeout(Duration::from_secs(5), guard_rx)
5376            .await
5377            .expect("dropped BidiStream should abort the headers task")
5378            .expect_err("guard sender should be dropped by the abort");
5379    }
5380
5381    #[tokio::test]
5382    async fn bidi_drop_aborts_pending_construction() {
5383        use buffa_types::google::protobuf::StringValue;
5384
5385        let (body, body_polled, body_release) = GatedBody::new();
5386        let (response_ready_tx, response_ready_rx) = tokio::sync::oneshot::channel();
5387        let response_task = tokio::spawn(async move {
5388            let response = connect_response(http::StatusCode::BAD_REQUEST, body);
5389            let _ = response_ready_tx.send(());
5390            Ok(response)
5391        });
5392        response_ready_rx
5393            .await
5394            .expect("response task should have prepared headers");
5395        let mut stream = bidi_stream_with_response_task(response_task, None);
5396
5397        let mut first_message = Box::pin(stream.message::<StringValue>());
5398        assert!(matches!(
5399            futures::poll!(&mut first_message),
5400            std::task::Poll::Pending
5401        ));
5402        body_polled
5403            .await
5404            .expect("Connect error body should be polled");
5405        drop(first_message);
5406        drop(stream);
5407
5408        // Aborting the construction task drops the gated response body; the
5409        // release handle then holds the only reference to the shared state.
5410        tokio::time::timeout(Duration::from_secs(5), async {
5411            while std::sync::Arc::strong_count(&body_release.shared) > 1 {
5412                tokio::task::yield_now().await;
5413            }
5414        })
5415        .await
5416        .expect("dropped BidiStream should abort construction and drop the body");
5417    }
5418
5419    #[tokio::test(start_paused = true)]
5420    async fn bidi_deadline_aborts_headers_task() {
5421        use buffa_types::google::protobuf::StringValue;
5422
5423        let (guard_tx, guard_rx) = tokio::sync::oneshot::channel::<()>();
5424        let (_never_tx, never_rx) =
5425            tokio::sync::oneshot::channel::<Result<Response<Full<Bytes>>, ConnectError>>();
5426        let response_task = tokio::spawn(async move {
5427            let _guard = guard_tx;
5428            never_rx.await.expect("test never resolves the response")
5429        });
5430        let deadline = std::time::Instant::now() + Duration::from_millis(100);
5431        let mut stream = bidi_stream_with_response_task(response_task, Some(deadline));
5432
5433        let err = stream
5434            .message::<StringValue>()
5435            .await
5436            .expect_err("deadline should fail receive initialization");
5437        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
5438
5439        // Failing the call at the deadline aborts (not detaches) the headers
5440        // task, dropping `_guard`.
5441        tokio::time::timeout(Duration::from_secs(5), guard_rx)
5442            .await
5443            .expect("deadline failure should abort the headers task")
5444            .expect_err("guard sender should be dropped by the abort");
5445    }
5446
5447    #[tokio::test]
5448    async fn connect_server_stream_truncated_after_data_errors() {
5449        use buffa::Message;
5450        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5451        use buffa_types::google::protobuf::StringValue;
5452
5453        let body = Full::new(Envelope::data(StringValue::from("hello").encode_to_bytes()).encode());
5454        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5455            headers: http::HeaderMap::new(),
5456            body,
5457            buf: BytesMut::new(),
5458            encoding: None,
5459            compression: CompressionRegistry::new(),
5460            codec_format: CodecFormat::Proto,
5461            protocol: Protocol::Connect,
5462            max_message_size: Some(1024),
5463            deadline: None,
5464            end: None,
5465            saw_body_data: false,
5466            _phantom: PhantomData,
5467        };
5468
5469        let msg = stream
5470            .message()
5471            .await
5472            .expect("first message should decode")
5473            .expect("stream should yield the data envelope before EOF");
5474        assert_eq!(msg.view().value, "hello");
5475
5476        let err = match stream.message().await {
5477            Err(err) => err,
5478            Ok(Some(_)) => panic!("truncated stream unexpectedly yielded another message"),
5479            Ok(None) => panic!("truncated stream ended cleanly without END_STREAM"),
5480        };
5481        assert_eq!(err.code, ErrorCode::Internal);
5482        assert!(
5483            err.to_string().contains("END_STREAM"),
5484            "unexpected error: {err}"
5485        );
5486
5487        // Sticky: a re-poll must not degrade truncation to a clean-looking
5488        // `Ok(None)`.
5489        let again = stream
5490            .message()
5491            .await
5492            .expect_err("truncation error must be sticky");
5493        assert_eq!(again.code, ErrorCode::Internal);
5494    }
5495
5496    /// A Connect streaming response whose body is empty (zero envelopes,
5497    /// immediate EOF) is also missing its END_STREAM envelope and must
5498    /// error rather than report a clean end of stream.
5499    #[tokio::test]
5500    async fn connect_server_stream_empty_body_errors() {
5501        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5502
5503        let body = Full::new(Bytes::new());
5504        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5505            headers: http::HeaderMap::new(),
5506            body,
5507            buf: BytesMut::new(),
5508            encoding: None,
5509            compression: CompressionRegistry::new(),
5510            codec_format: CodecFormat::Proto,
5511            protocol: Protocol::Connect,
5512            max_message_size: Some(1024),
5513            deadline: None,
5514            end: None,
5515            saw_body_data: false,
5516            _phantom: PhantomData,
5517        };
5518
5519        let err = match stream.message().await {
5520            Err(err) => err,
5521            Ok(Some(_)) => panic!("empty body unexpectedly yielded a message"),
5522            Ok(None) => panic!("empty body without END_STREAM ended cleanly"),
5523        };
5524        assert_eq!(err.code, ErrorCode::Internal);
5525        assert!(
5526            err.to_string().contains("END_STREAM"),
5527            "unexpected error: {err}"
5528        );
5529
5530        let again = stream
5531            .message()
5532            .await
5533            .expect_err("truncation error must be sticky");
5534        assert_eq!(again.code, ErrorCode::Internal);
5535    }
5536
5537    /// `Ok(None)` means the RPC succeeded — a Connect END_STREAM envelope
5538    /// carrying an error must come back as `Err` from `message()`, sticky
5539    /// across calls, with `error()` still available for inspection.
5540    #[tokio::test]
5541    async fn connect_end_stream_error_returned_from_message() {
5542        use buffa::Message;
5543        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5544        use buffa_types::google::protobuf::StringValue;
5545
5546        let mut body = BytesMut::new();
5547        body.extend_from_slice(
5548            &Envelope::data(StringValue::from("hello").encode_to_bytes()).encode(),
5549        );
5550        body.extend_from_slice(
5551            &Envelope::end_stream(Bytes::from_static(
5552                b"{\"error\":{\"code\":\"out_of_range\",\"message\":\"requested position no longer retained\"},\
5553                  \"metadata\":{\"x-detail\":[\"42\"]}}",
5554            ))
5555            .encode(),
5556        );
5557        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5558            headers: http::HeaderMap::new(),
5559            body: Full::new(body.freeze()),
5560            buf: BytesMut::new(),
5561            encoding: None,
5562            compression: CompressionRegistry::new(),
5563            codec_format: CodecFormat::Proto,
5564            protocol: Protocol::Connect,
5565            max_message_size: Some(1024),
5566            deadline: None,
5567            end: None,
5568            saw_body_data: false,
5569            _phantom: PhantomData,
5570        };
5571
5572        let msg = stream
5573            .message()
5574            .await
5575            .expect("data envelope should decode")
5576            .expect("stream should yield the data message first");
5577        assert_eq!(msg.view().value, "hello");
5578
5579        let err = stream
5580            .message()
5581            .await
5582            .expect_err("errored END_STREAM must surface as Err, not Ok(None)");
5583        assert_eq!(err.code, ErrorCode::OutOfRange);
5584        assert_eq!(
5585            err.message.as_deref(),
5586            Some("requested position no longer retained")
5587        );
5588
5589        // Sticky: re-polling a failed stream re-reports the failure.
5590        let again = stream
5591            .message()
5592            .await
5593            .expect_err("terminal error is sticky");
5594        assert_eq!(again.code, ErrorCode::OutOfRange);
5595
5596        // Post-hoc accessors still work.
5597        assert_eq!(stream.error().map(|e| e.code), Some(ErrorCode::OutOfRange));
5598        assert_eq!(
5599            stream
5600                .trailers()
5601                .and_then(|t| t.get("x-detail"))
5602                .and_then(|v| v.to_str().ok()),
5603            Some("42")
5604        );
5605    }
5606
5607    /// Malformed Connect END_STREAM JSON is a protocol error. It must not be
5608    /// treated as an empty successful end-stream payload.
5609    #[tokio::test]
5610    async fn connect_malformed_end_stream_json_errors() {
5611        use buffa::Message;
5612        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5613        use buffa_types::google::protobuf::StringValue;
5614
5615        let mut body = BytesMut::new();
5616        body.extend_from_slice(
5617            &Envelope::data(StringValue::from("hello").encode_to_bytes()).encode(),
5618        );
5619        body.extend_from_slice(&Envelope::end_stream(Bytes::from_static(b"not json")).encode());
5620
5621        let mut headers = http::HeaderMap::new();
5622        headers.insert("x-from-headers", http::HeaderValue::from_static("yes"));
5623
5624        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5625            headers,
5626            body: Full::new(body.freeze()),
5627            buf: BytesMut::new(),
5628            encoding: None,
5629            compression: CompressionRegistry::new(),
5630            codec_format: CodecFormat::Proto,
5631            protocol: Protocol::Connect,
5632            max_message_size: Some(1024),
5633            deadline: None,
5634            end: None,
5635            saw_body_data: false,
5636            _phantom: PhantomData,
5637        };
5638
5639        let msg = stream
5640            .message()
5641            .await
5642            .expect("data envelope should decode")
5643            .expect("stream should yield the data message first");
5644        assert_eq!(msg.view().value, "hello");
5645
5646        let err = stream
5647            .message()
5648            .await
5649            .expect_err("malformed END_STREAM must surface as Err, not Ok(None)");
5650        assert_eq!(err.code, ErrorCode::Internal);
5651        assert!(
5652            err.to_string()
5653                .contains("malformed Connect END_STREAM JSON"),
5654            "unexpected error: {err}"
5655        );
5656        assert_eq!(err.response_headers().get("x-from-headers").unwrap(), "yes");
5657
5658        let again = stream
5659            .message()
5660            .await
5661            .expect_err("malformed END_STREAM error must be sticky");
5662        assert_eq!(again.code, ErrorCode::Internal);
5663        assert_eq!(
5664            again.response_headers().get("x-from-headers").unwrap(),
5665            "yes"
5666        );
5667    }
5668
5669    /// A server error carried by a well-formed Connect END_STREAM is still
5670    /// an error the caller inspects for context: it must arrive with the
5671    /// response headers and with the trailing metadata the same envelope
5672    /// supplied, on the first read and on every sticky replay after it.
5673    #[tokio::test]
5674    async fn connect_end_stream_error_carries_response_metadata() {
5675        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5676
5677        let mut body = BytesMut::new();
5678        body.extend_from_slice(
5679            &Envelope::end_stream(Bytes::from_static(
5680                b"{\"error\":{\"code\":\"permission_denied\",\"message\":\"nope\"},\
5681                  \"metadata\":{\"x-from-trailers\":[\"also\"]}}",
5682            ))
5683            .encode(),
5684        );
5685
5686        let mut headers = http::HeaderMap::new();
5687        headers.insert("x-from-headers", http::HeaderValue::from_static("yes"));
5688
5689        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5690            headers,
5691            body: Full::new(body.freeze()),
5692            buf: BytesMut::new(),
5693            encoding: None,
5694            compression: CompressionRegistry::new(),
5695            codec_format: CodecFormat::Proto,
5696            protocol: Protocol::Connect,
5697            max_message_size: Some(1024),
5698            deadline: None,
5699            end: None,
5700            saw_body_data: false,
5701            _phantom: PhantomData,
5702        };
5703
5704        let err = stream
5705            .message()
5706            .await
5707            .expect_err("errored END_STREAM must surface as Err");
5708        assert_eq!(err.code, ErrorCode::PermissionDenied);
5709        assert_eq!(err.response_headers().get("x-from-headers").unwrap(), "yes");
5710        assert_eq!(err.trailers().get("x-from-trailers").unwrap(), "also");
5711
5712        let again = stream
5713            .message()
5714            .await
5715            .expect_err("terminal error is sticky");
5716        assert_eq!(
5717            again.response_headers().get("x-from-headers").unwrap(),
5718            "yes"
5719        );
5720        assert_eq!(again.trailers().get("x-from-trailers").unwrap(), "also");
5721
5722        // The stored record is the same one `message()` replayed, so the
5723        // post-hoc accessor cannot disagree with it.
5724        let stored = stream.error().expect("terminal error stays inspectable");
5725        assert_eq!(
5726            stored.response_headers().get("x-from-headers").unwrap(),
5727            "yes"
5728        );
5729        assert_eq!(stored.trailers().get("x-from-trailers").unwrap(), "also");
5730    }
5731
5732    /// The same guarantee on the gRPC shape: a `grpc-status` error in the
5733    /// trailers reaches the caller with the response headers attached, not
5734    /// just the trailers it was parsed from.
5735    ///
5736    /// The error's metadata and the stream's trailers are deliberately
5737    /// different views of the same frame: the status-bearing keys are
5738    /// already the error's `code` and `message`, so they stay out of the
5739    /// metadata while `trailers()` keeps reporting the wire map verbatim.
5740    #[tokio::test]
5741    async fn grpc_stream_error_carries_response_metadata() {
5742        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5743        use http_body::Frame;
5744        use http_body_util::StreamBody;
5745
5746        let mut trailers = http::HeaderMap::new();
5747        trailers.insert("grpc-status", "7".parse().unwrap()); // PERMISSION_DENIED
5748        trailers.insert("grpc-message", "nope".parse().unwrap());
5749        trailers.insert("x-from-trailers", "also".parse().unwrap());
5750        let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
5751            vec![Ok(Frame::trailers(trailers))];
5752
5753        let mut headers = http::HeaderMap::new();
5754        headers.insert("x-from-headers", http::HeaderValue::from_static("yes"));
5755
5756        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5757            headers,
5758            body: StreamBody::new(futures::stream::iter(frames)),
5759            buf: BytesMut::new(),
5760            encoding: None,
5761            compression: CompressionRegistry::new(),
5762            codec_format: CodecFormat::Proto,
5763            protocol: Protocol::Grpc,
5764            max_message_size: Some(1024),
5765            deadline: None,
5766            end: None,
5767            saw_body_data: false,
5768            _phantom: PhantomData,
5769        };
5770
5771        let err = stream
5772            .message()
5773            .await
5774            .expect_err("grpc-status 7 must surface as Err");
5775        assert_eq!(err.code, ErrorCode::PermissionDenied);
5776        assert_eq!(err.message.as_deref(), Some("nope"));
5777        assert_eq!(err.response_headers().get("x-from-headers").unwrap(), "yes");
5778        assert_eq!(err.trailers().get("x-from-trailers").unwrap(), "also");
5779
5780        // The status keys stay out of the error metadata — they are the
5781        // error's own code and message — but remain in the wire trailers.
5782        for key in [
5783            &hdr::GRPC_STATUS,
5784            &hdr::GRPC_MESSAGE,
5785            &hdr::GRPC_STATUS_DETAILS_BIN,
5786        ] {
5787            assert!(
5788                !err.trailers().contains_key(key),
5789                "{key} must not be duplicated into the error metadata"
5790            );
5791        }
5792        let wire = stream.trailers().expect("wire trailers stay available");
5793        assert_eq!(wire.get("grpc-status").unwrap(), "7");
5794        assert_eq!(wire.get("x-from-trailers").unwrap(), "also");
5795    }
5796
5797    /// The corner that makes the filter, not an "already has trailers"
5798    /// check, the right guard: when the frame carries nothing but status
5799    /// keys the curated metadata is legitimately empty, and the raw map
5800    /// must not be substituted for it.
5801    #[tokio::test]
5802    async fn grpc_stream_error_metadata_is_empty_when_only_status_arrives() {
5803        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5804        use http_body::Frame;
5805        use http_body_util::StreamBody;
5806
5807        let mut trailers = http::HeaderMap::new();
5808        trailers.insert("grpc-status", "7".parse().unwrap());
5809        let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
5810            vec![Ok(Frame::trailers(trailers))];
5811
5812        let mut headers = http::HeaderMap::new();
5813        headers.insert("x-from-headers", http::HeaderValue::from_static("yes"));
5814
5815        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5816            headers,
5817            body: StreamBody::new(futures::stream::iter(frames)),
5818            buf: BytesMut::new(),
5819            encoding: None,
5820            compression: CompressionRegistry::new(),
5821            codec_format: CodecFormat::Proto,
5822            protocol: Protocol::Grpc,
5823            max_message_size: Some(1024),
5824            deadline: None,
5825            end: None,
5826            saw_body_data: false,
5827            _phantom: PhantomData,
5828        };
5829
5830        let err = stream
5831            .message()
5832            .await
5833            .expect_err("grpc-status 7 must surface as Err");
5834        assert!(
5835            err.trailers().is_empty(),
5836            "status-only trailers carry no user metadata: {:?}",
5837            err.trailers()
5838        );
5839        // The empty metadata must not read as "nothing was attached": the
5840        // headers still arrive, which is what distinguishes the filtered
5841        // map from a record that skipped attachment altogether.
5842        assert_eq!(err.response_headers().get("x-from-headers").unwrap(), "yes");
5843        assert!(
5844            stream
5845                .trailers()
5846                .is_some_and(|t| t.contains_key("grpc-status")),
5847            "the wire trailers are still reported verbatim"
5848        );
5849    }
5850
5851    /// A Connect unary response whose content-type is not the configured
5852    /// codec's is rejected without reading the body. The rejection still
5853    /// carries the headers (and the `trailer-`-prefixed metadata) that
5854    /// arrived, so a caller can see what the intermediary actually sent.
5855    #[tokio::test]
5856    async fn connect_unary_content_type_rejection_carries_response_metadata() {
5857        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5858
5859        let response = Response::builder()
5860            .status(http::StatusCode::OK)
5861            .header(http::header::CONTENT_TYPE, "text/html")
5862            .header("x-from-headers", "yes")
5863            .header("trailer-x-from-trailers", "also")
5864            .body(Full::new(Bytes::new()))
5865            .unwrap();
5866
5867        let config = ClientConfig::new("http://localhost".parse().unwrap());
5868        let err = parse_connect_unary_response::<_, StringValueView<'static>>(
5869            response,
5870            &config,
5871            &CallOptions::default(),
5872            None,
5873        )
5874        .await
5875        .expect_err("text/html is not a Connect response");
5876
5877        assert_eq!(err.code, ErrorCode::Unknown);
5878        assert_eq!(err.response_headers().get("x-from-headers").unwrap(), "yes");
5879        assert_eq!(err.trailers().get("x-from-trailers").unwrap(), "also");
5880    }
5881
5882    /// Builds a response whose body fails on the first poll, so the error-body
5883    /// read inside a non-2xx branch fails rather than the branch's own parse.
5884    fn erroring_body_response(status: http::StatusCode) -> Response<ChannelBody> {
5885        let (tx, rx) = tokio::sync::mpsc::channel(1);
5886        tx.try_send(Err(ConnectError::internal("transport reset")))
5887            .unwrap();
5888        Response::builder()
5889            .status(status)
5890            .header(http::header::CONTENT_TYPE, "application/json")
5891            .header("x-from-headers", "yes")
5892            .body(ChannelBody { rx })
5893            .unwrap()
5894    }
5895
5896    /// A non-2xx server-stream response whose error body cannot be read is
5897    /// still a terminal error, and the response headers are the only context
5898    /// the caller has left. The unary path has always attached them here; the
5899    /// streaming paths did not, which made the metadata a caller sees depend
5900    /// on the shape of the call rather than on what happened.
5901    #[tokio::test]
5902    async fn server_stream_error_body_read_failure_carries_headers() {
5903        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5904
5905        // let-else, not `expect_err`: `ServerStream` is not `Debug`.
5906        let Err(err) = make_server_stream::<_, StringValueView<'static>>(
5907            erroring_body_response(http::StatusCode::INTERNAL_SERVER_ERROR),
5908            Protocol::Connect,
5909            &CompressionRegistry::default(),
5910            CodecFormat::Json,
5911            None,
5912            None,
5913        )
5914        .await
5915        else {
5916            panic!("a body that fails to read is an error");
5917        };
5918
5919        assert_eq!(
5920            err.response_headers()
5921                .get("x-from-headers")
5922                .map(|v| v.to_str().unwrap()),
5923            Some("yes"),
5924            "server-stream error-body read failure dropped the response headers"
5925        );
5926    }
5927
5928    /// The client-stream twin of the above.
5929    #[tokio::test]
5930    async fn client_stream_error_body_read_failure_carries_headers() {
5931        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5932
5933        let config = ClientConfig::new("http://localhost".parse().unwrap());
5934        let err = parse_connect_client_stream_response::<_, StringValueView<'static>>(
5935            erroring_body_response(http::StatusCode::INTERNAL_SERVER_ERROR),
5936            &config,
5937            &CallOptions::default(),
5938            None,
5939        )
5940        .await
5941        .expect_err("a body that fails to read is an error");
5942
5943        assert_eq!(
5944            err.response_headers()
5945                .get("x-from-headers")
5946                .map(|v| v.to_str().unwrap()),
5947            Some("yes"),
5948            "client-stream error-body read failure dropped the response headers"
5949        );
5950    }
5951
5952    /// Same contract for gRPC: an error in HTTP/2 trailers is a failed RPC
5953    /// and must come back as `Err` from `message()` — not the silent
5954    /// `Ok(None)` that callers mistake for a clean close.
5955    #[tokio::test]
5956    async fn grpc_trailer_error_returned_from_message() {
5957        use buffa::Message;
5958        use buffa_types::google::protobuf::__buffa::view::StringValueView;
5959        use buffa_types::google::protobuf::StringValue;
5960        use http_body::Frame;
5961        use http_body_util::StreamBody;
5962
5963        let data = Envelope::data(StringValue::from("hello").encode_to_bytes()).encode();
5964        let mut trailers = http::HeaderMap::new();
5965        trailers.insert("grpc-status", "11".parse().unwrap()); // OUT_OF_RANGE
5966        trailers.insert(
5967            "grpc-message",
5968            "requested position no longer retained".parse().unwrap(),
5969        );
5970        let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
5971            vec![Ok(Frame::data(data)), Ok(Frame::trailers(trailers))];
5972        let body = StreamBody::new(futures::stream::iter(frames));
5973
5974        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
5975            headers: http::HeaderMap::new(),
5976            body,
5977            buf: BytesMut::new(),
5978            encoding: None,
5979            compression: CompressionRegistry::new(),
5980            codec_format: CodecFormat::Proto,
5981            protocol: Protocol::Grpc,
5982            max_message_size: Some(1024),
5983            deadline: None,
5984            end: None,
5985            saw_body_data: false,
5986            _phantom: PhantomData,
5987        };
5988
5989        let msg = stream
5990            .message()
5991            .await
5992            .expect("data envelope should decode")
5993            .expect("stream should yield the data message first");
5994        assert_eq!(msg.view().value, "hello");
5995
5996        let err = stream
5997            .message()
5998            .await
5999            .expect_err("gRPC trailer error must surface as Err, not Ok(None)");
6000        assert_eq!(err.code, ErrorCode::OutOfRange);
6001        assert_eq!(
6002            err.message.as_deref(),
6003            Some("requested position no longer retained")
6004        );
6005
6006        let again = stream
6007            .message()
6008            .await
6009            .expect_err("terminal error is sticky");
6010        assert_eq!(again.code, ErrorCode::OutOfRange);
6011        assert!(stream.trailers().is_some());
6012    }
6013
6014    /// A gRPC stream ending with `grpc-status: 0` is the one true clean end —
6015    /// `Ok(None)`, no error. Runs with an unexpired deadline set, so an
6016    /// implementation that errors eagerly on any deadline would fail here.
6017    #[tokio::test]
6018    async fn grpc_ok_trailers_end_as_ok_none() {
6019        use std::time::Duration;
6020
6021        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6022        use http_body::Frame;
6023        use http_body_util::StreamBody;
6024
6025        let mut trailers = http::HeaderMap::new();
6026        trailers.insert("grpc-status", "0".parse().unwrap());
6027        let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
6028            vec![Ok(Frame::trailers(trailers))];
6029        let body = StreamBody::new(futures::stream::iter(frames));
6030
6031        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
6032            headers: http::HeaderMap::new(),
6033            body,
6034            buf: BytesMut::new(),
6035            encoding: None,
6036            compression: CompressionRegistry::new(),
6037            codec_format: CodecFormat::Proto,
6038            protocol: Protocol::Grpc,
6039            max_message_size: Some(1024),
6040            deadline: Some(std::time::Instant::now() + Duration::from_secs(5)),
6041            end: None,
6042            saw_body_data: false,
6043            _phantom: PhantomData,
6044        };
6045
6046        assert!(stream.message().await.unwrap().is_none());
6047        assert!(stream.error().is_none());
6048        assert!(stream.trailers().is_some());
6049    }
6050
6051    /// gRPC EOF with no trailers at all is a protocol violation — it must
6052    /// not read as a clean end (it is indistinguishable from a mid-stream
6053    /// cut; grpc-go errors here too).
6054    #[tokio::test]
6055    async fn grpc_eof_without_trailers_errors() {
6056        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6057
6058        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
6059            headers: http::HeaderMap::new(),
6060            body: Full::new(Bytes::new()),
6061            buf: BytesMut::new(),
6062            encoding: None,
6063            compression: CompressionRegistry::new(),
6064            codec_format: CodecFormat::Proto,
6065            protocol: Protocol::Grpc,
6066            max_message_size: Some(1024),
6067            deadline: None,
6068            end: None,
6069            saw_body_data: false,
6070            _phantom: PhantomData,
6071        };
6072
6073        let err = stream
6074            .message()
6075            .await
6076            .expect_err("EOF without grpc-status must surface as Err");
6077        assert_eq!(err.code, ErrorCode::Internal);
6078        assert!(
6079            err.to_string().contains("grpc-status"),
6080            "unexpected error: {err}"
6081        );
6082
6083        let again = stream
6084            .message()
6085            .await
6086            .expect_err("missing-status error must be sticky");
6087        assert_eq!(again.code, ErrorCode::Internal);
6088    }
6089
6090    /// `grpc-status: 0` in the response HEADERS only certifies a true
6091    /// Trailers-Only response (empty body). If data flowed afterwards,
6092    /// the real trailers are still required — a cut after eager headers
6093    /// must not read as success.
6094    #[tokio::test]
6095    async fn grpc_header_status_does_not_excuse_truncation_after_data() {
6096        use buffa::Message;
6097        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6098        use buffa_types::google::protobuf::StringValue;
6099
6100        let mut headers = http::HeaderMap::new();
6101        headers.insert("grpc-status", "0".parse().unwrap());
6102        let body = Full::new(Envelope::data(StringValue::from("hello").encode_to_bytes()).encode());
6103        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
6104            headers,
6105            body,
6106            buf: BytesMut::new(),
6107            encoding: None,
6108            compression: CompressionRegistry::new(),
6109            codec_format: CodecFormat::Proto,
6110            protocol: Protocol::Grpc,
6111            max_message_size: Some(1024),
6112            deadline: None,
6113            end: None,
6114            saw_body_data: false,
6115            _phantom: PhantomData,
6116        };
6117
6118        let msg = stream
6119            .message()
6120            .await
6121            .expect("data envelope should decode")
6122            .expect("stream should yield the data message first");
6123        assert_eq!(msg.view().value, "hello");
6124
6125        let err = stream
6126            .message()
6127            .await
6128            .expect_err("truncation after data must surface as Err despite header status");
6129        assert_eq!(err.code, ErrorCode::Internal);
6130    }
6131
6132    /// A gRPC Trailers-Only OK response — `grpc-status: 0` in the response
6133    /// HEADERS, empty body, no HTTP trailers — is how grpc-go ends a
6134    /// server-stream cleanly with zero messages. It must stay a clean
6135    /// `Ok(None)`, not a missing-status error.
6136    #[tokio::test]
6137    async fn grpc_trailers_only_ok_response_ends_cleanly() {
6138        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6139
6140        let mut headers = http::HeaderMap::new();
6141        headers.insert("grpc-status", "0".parse().unwrap());
6142        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
6143            headers,
6144            body: Full::new(Bytes::new()),
6145            buf: BytesMut::new(),
6146            encoding: None,
6147            compression: CompressionRegistry::new(),
6148            codec_format: CodecFormat::Proto,
6149            protocol: Protocol::Grpc,
6150            max_message_size: Some(1024),
6151            deadline: None,
6152            end: None,
6153            saw_body_data: false,
6154            _phantom: PhantomData,
6155        };
6156
6157        assert!(stream.message().await.unwrap().is_none());
6158        assert!(stream.error().is_none());
6159        // Stays clean on re-poll, too.
6160        assert!(stream.message().await.unwrap().is_none());
6161    }
6162
6163    /// Trailers that arrive without any `grpc-status` are as broken as no
6164    /// trailers at all — the status is the termination signal, and its
6165    /// absence must not read as success (grpc-go maps this to an error).
6166    #[tokio::test]
6167    async fn grpc_trailers_without_status_errors() {
6168        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6169        use http_body::Frame;
6170        use http_body_util::StreamBody;
6171
6172        let mut trailers = http::HeaderMap::new();
6173        trailers.insert("x-meta", "1".parse().unwrap());
6174        let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
6175            vec![Ok(Frame::trailers(trailers))];
6176        let body = StreamBody::new(futures::stream::iter(frames));
6177
6178        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
6179            headers: http::HeaderMap::new(),
6180            body,
6181            buf: BytesMut::new(),
6182            encoding: None,
6183            compression: CompressionRegistry::new(),
6184            codec_format: CodecFormat::Proto,
6185            protocol: Protocol::Grpc,
6186            max_message_size: Some(1024),
6187            deadline: None,
6188            end: None,
6189            saw_body_data: false,
6190            _phantom: PhantomData,
6191        };
6192
6193        let err = stream
6194            .message()
6195            .await
6196            .expect_err("trailers without grpc-status must surface as Err");
6197        // Unknown, not Internal: trailers arrived, just without a status —
6198        // grpc-go / connect-go / conformance-primary semantics.
6199        assert_eq!(err.code, ErrorCode::Unknown);
6200        // The malformed trailers are still inspectable.
6201        assert!(stream.trailers().is_some());
6202
6203        let again = stream
6204            .message()
6205            .await
6206            .expect_err("missing-status error must be sticky");
6207        assert_eq!(again.code, ErrorCode::Unknown);
6208    }
6209
6210    /// The whole-call deadline is ABSOLUTE across frame polls: a server
6211    /// trickling frames forever, each arriving well inside any plausible
6212    /// per-poll window, is stopped at the deadline. This pins the
6213    /// equivalence that licenses bounding each frame poll instead of the
6214    /// whole decode loop — a per-poll *relative* timeout would let every
6215    /// 40ms frame through and this test would fail (the trickled bytes
6216    /// eventually complete an envelope and yield `Ok(Some)`).
6217    #[tokio::test(start_paused = true)]
6218    async fn deadline_bounds_multi_frame_message() {
6219        use std::time::Duration;
6220
6221        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6222        use http_body::Frame;
6223        use http_body_util::StreamBody;
6224
6225        // One opaque byte every 40ms, forever (bounded at 32 so a
6226        // regression fails fast instead of hanging) — never enough to
6227        // matter before a 100ms absolute deadline.
6228        let frames: std::pin::Pin<
6229            Box<dyn futures::Stream<Item = Result<Frame<Bytes>, std::convert::Infallible>> + Send>,
6230        > = Box::pin(futures::stream::unfold(0u32, |n| async move {
6231            if n >= 32 {
6232                return None;
6233            }
6234            tokio::time::sleep(Duration::from_millis(40)).await;
6235            Some((
6236                Ok::<_, std::convert::Infallible>(Frame::data(Bytes::from_static(&[0u8]))),
6237                n + 1,
6238            ))
6239        }));
6240        let body = StreamBody::new(frames);
6241
6242        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
6243            headers: http::HeaderMap::new(),
6244            body,
6245            buf: BytesMut::new(),
6246            encoding: None,
6247            compression: CompressionRegistry::new(),
6248            codec_format: CodecFormat::Proto,
6249            protocol: Protocol::Grpc,
6250            max_message_size: Some(1024),
6251            deadline: Some(std::time::Instant::now() + Duration::from_millis(100)),
6252            end: None,
6253            saw_body_data: false,
6254            _phantom: PhantomData,
6255        };
6256
6257        let start = tokio::time::Instant::now();
6258        let err = stream
6259            .message()
6260            .await
6261            .expect_err("absolute deadline must fire mid-trickle");
6262        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
6263        assert!(
6264            start.elapsed() >= Duration::from_millis(100),
6265            "deadline must not fire early (elapsed {:?})",
6266            start.elapsed()
6267        );
6268        assert!(
6269            start.elapsed() <= Duration::from_millis(150),
6270            "deadline must be absolute across polls, not per-poll relative (elapsed {:?})",
6271            start.elapsed()
6272        );
6273
6274        let again = stream
6275            .message()
6276            .await
6277            .expect_err("deadline error is sticky");
6278        assert_eq!(again.code, ErrorCode::DeadlineExceeded);
6279    }
6280
6281    /// A present-but-garbage `grpc-status` is a protocol error (`unknown`,
6282    /// grpc-go parity) — it must not satisfy the status-presence check and
6283    /// read as a clean end.
6284    #[tokio::test]
6285    async fn grpc_malformed_status_errors() {
6286        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6287        use http_body::Frame;
6288        use http_body_util::StreamBody;
6289
6290        let mut trailers = http::HeaderMap::new();
6291        trailers.insert("grpc-status", "banana".parse().unwrap());
6292        let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
6293            vec![Ok(Frame::trailers(trailers))];
6294        let body = StreamBody::new(futures::stream::iter(frames));
6295
6296        let mut stream: ServerStream<_, StringValueView<'static>> = ServerStream {
6297            headers: http::HeaderMap::new(),
6298            body,
6299            buf: BytesMut::new(),
6300            encoding: None,
6301            compression: CompressionRegistry::new(),
6302            codec_format: CodecFormat::Proto,
6303            protocol: Protocol::Grpc,
6304            max_message_size: Some(1024),
6305            deadline: None,
6306            end: None,
6307            saw_body_data: false,
6308            _phantom: PhantomData,
6309        };
6310
6311        let err = stream
6312            .message()
6313            .await
6314            .expect_err("malformed grpc-status must surface as Err");
6315        assert_eq!(err.code, ErrorCode::Unknown);
6316        assert!(
6317            err.to_string().contains("malformed grpc-status"),
6318            "unexpected error: {err}"
6319        );
6320
6321        let again = stream
6322            .message()
6323            .await
6324            .expect_err("malformed-status error must be sticky");
6325        assert_eq!(again.code, ErrorCode::Unknown);
6326    }
6327
6328    #[cfg(feature = "client")]
6329    fn plaintext_client_with_https_base() -> (HttpClient, ClientConfig) {
6330        let client = HttpClient::plaintext();
6331        let config = ClientConfig::new("https://localhost:8080".parse().unwrap());
6332        (client, config)
6333    }
6334
6335    #[test]
6336    fn transport_send_error_mapper_preserves_connect_error_in_source_chain() {
6337        #[derive(Debug)]
6338        struct WrappedTransportError(ConnectError);
6339
6340        impl std::fmt::Display for WrappedTransportError {
6341            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6342                write!(f, "wrapped transport failure")
6343            }
6344        }
6345
6346        impl std::error::Error for WrappedTransportError {
6347            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
6348                Some(&self.0)
6349            }
6350        }
6351
6352        let mapped = map_transport_send_error(
6353            WrappedTransportError(ConnectError::invalid_argument("bad client config")),
6354            "request failed",
6355        );
6356        assert_eq!(mapped.code, ErrorCode::InvalidArgument);
6357        assert_eq!(mapped.message.as_deref(), Some("bad client config"));
6358    }
6359
6360    #[test]
6361    fn transport_send_error_mapper_preserves_source_when_no_connect_error_in_chain() {
6362        #[derive(Debug)]
6363        struct PlainTransportError;
6364
6365        impl std::fmt::Display for PlainTransportError {
6366            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6367                write!(f, "connection reset by peer")
6368            }
6369        }
6370
6371        impl std::error::Error for PlainTransportError {}
6372
6373        let mapped = map_transport_send_error(PlainTransportError, "request failed");
6374        assert_eq!(mapped.code, ErrorCode::Unavailable);
6375        let source = std::error::Error::source(&mapped).expect("source must be preserved");
6376        assert_eq!(source.to_string(), "connection reset by peer");
6377    }
6378
6379    #[cfg(feature = "client")]
6380    #[tokio::test]
6381    async fn call_unary_preserves_transport_connect_error() {
6382        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6383        use buffa_types::google::protobuf::StringValue;
6384
6385        let (client, config) = plaintext_client_with_https_base();
6386        let err = call_unary::<_, StringValue, StringValueView<'static>>(
6387            &client,
6388            &config,
6389            "test.Service",
6390            "Unary",
6391            StringValue::from("hello"),
6392            CallOptions::default(),
6393        )
6394        .await
6395        .expect_err("transport config error must surface from unary call");
6396        assert_eq!(err.code, ErrorCode::InvalidArgument);
6397        assert!(err.message.as_deref().unwrap().contains("with_tls"));
6398    }
6399
6400    #[cfg(feature = "client")]
6401    #[tokio::test]
6402    async fn call_unary_get_preserves_transport_connect_error() {
6403        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6404        use buffa_types::google::protobuf::StringValue;
6405
6406        let (client, config) = plaintext_client_with_https_base();
6407        let err = call_unary_get::<_, StringValue, StringValueView<'static>>(
6408            &client,
6409            &config,
6410            "test.Service",
6411            "UnaryGet",
6412            StringValue::from("hello"),
6413            CallOptions::default(),
6414        )
6415        .await
6416        .expect_err("transport config error must surface from unary GET");
6417        assert_eq!(err.code, ErrorCode::InvalidArgument);
6418        assert!(err.message.as_deref().unwrap().contains("with_tls"));
6419    }
6420
6421    #[cfg(feature = "client")]
6422    #[tokio::test]
6423    async fn call_server_stream_preserves_transport_connect_error() {
6424        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6425        use buffa_types::google::protobuf::StringValue;
6426
6427        let (client, config) = plaintext_client_with_https_base();
6428        let err = call_server_stream::<_, StringValue, StringValueView<'static>>(
6429            &client,
6430            &config,
6431            "test.Service",
6432            "ServerStream",
6433            StringValue::from("hello"),
6434            CallOptions::default(),
6435        )
6436        .await
6437        .expect_err("transport config error must surface from server stream");
6438        assert_eq!(err.code, ErrorCode::InvalidArgument);
6439        assert!(err.message.as_deref().unwrap().contains("with_tls"));
6440    }
6441
6442    #[cfg(feature = "client")]
6443    #[tokio::test]
6444    async fn call_bidi_stream_preserves_transport_connect_error() {
6445        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6446        use buffa_types::google::protobuf::StringValue;
6447
6448        let (client, config) = plaintext_client_with_https_base();
6449        let mut stream = call_bidi_stream::<_, StringValue, StringValueView<'static>>(
6450            &client,
6451            &config,
6452            "test.Service",
6453            "Bidi",
6454            CallOptions::default(),
6455        )
6456        .await
6457        .expect("constructing bidi stream should succeed until the first receive");
6458        let err = stream
6459            .message()
6460            .await
6461            .expect_err("transport config error must surface from bidi receive");
6462        assert_eq!(err.code, ErrorCode::InvalidArgument);
6463        assert!(err.message.as_deref().unwrap().contains("with_tls"));
6464        assert_eq!(
6465            stream.error().map(|e| e.code),
6466            Some(ErrorCode::InvalidArgument)
6467        );
6468    }
6469
6470    #[cfg(feature = "client")]
6471    #[tokio::test]
6472    async fn call_client_stream_preserves_transport_connect_error() {
6473        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6474        use buffa_types::google::protobuf::StringValue;
6475
6476        let (client, config) = plaintext_client_with_https_base();
6477        let err = call_client_stream::<_, StringValue, StringValueView<'static>>(
6478            &client,
6479            &config,
6480            "test.Service",
6481            "ClientStream",
6482            futures::stream::iter([StringValue::from("hello")]),
6483            CallOptions::default(),
6484        )
6485        .await
6486        .expect_err("transport config error must surface from client stream");
6487        assert_eq!(err.code, ErrorCode::InvalidArgument);
6488        assert!(err.message.as_deref().unwrap().contains("with_tls"));
6489    }
6490
6491    // A transport whose `send()` future never resolves. It signals when it is
6492    // first polled and again when it is dropped, so a test can assert that
6493    // abandoning `call_client_stream` actually drops the in-flight transport
6494    // send future rather than leaking it in a detached task.
6495    #[cfg(feature = "client")]
6496    #[derive(Clone)]
6497    struct PendingSendTransport {
6498        started: std::sync::Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
6499        dropped: std::sync::Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
6500    }
6501
6502    #[cfg(feature = "client")]
6503    struct PendingSendFuture {
6504        // Hold the request (and thus the stream-backed body) without ever
6505        // reading it, so any request messages stay unread inside the body.
6506        // Dropping this future drops the request too.
6507        _request: Request<ClientBody>,
6508        started: Option<tokio::sync::oneshot::Sender<()>>,
6509        dropped: Option<tokio::sync::oneshot::Sender<()>>,
6510    }
6511
6512    #[cfg(feature = "client")]
6513    impl std::future::Future for PendingSendFuture {
6514        type Output = Result<Response<Full<Bytes>>, std::io::Error>;
6515
6516        fn poll(
6517            mut self: std::pin::Pin<&mut Self>,
6518            _cx: &mut std::task::Context<'_>,
6519        ) -> std::task::Poll<Self::Output> {
6520            if let Some(tx) = self.started.take() {
6521                let _ = tx.send(());
6522            }
6523            std::task::Poll::Pending
6524        }
6525    }
6526
6527    #[cfg(feature = "client")]
6528    impl Drop for PendingSendFuture {
6529        fn drop(&mut self) {
6530            if let Some(tx) = self.dropped.take() {
6531                let _ = tx.send(());
6532            }
6533        }
6534    }
6535
6536    #[cfg(feature = "client")]
6537    impl ClientTransport for PendingSendTransport {
6538        type ResponseBody = Full<Bytes>;
6539        type Error = std::io::Error;
6540
6541        fn send(
6542            &self,
6543            request: Request<ClientBody>,
6544        ) -> BoxFuture<'static, Result<Response<Self::ResponseBody>, Self::Error>> {
6545            // Single-shot: the streaming call paths invoke `send` exactly once.
6546            // Panic loudly rather than silently swallow the started/dropped
6547            // signals if that ever stops holding, which would otherwise hang the
6548            // test.
6549            let started = Some(
6550                self.started
6551                    .lock()
6552                    .unwrap()
6553                    .take()
6554                    .expect("PendingSendTransport::send called more than once"),
6555            );
6556            let dropped = Some(
6557                self.dropped
6558                    .lock()
6559                    .unwrap()
6560                    .take()
6561                    .expect("PendingSendTransport::send called more than once"),
6562            );
6563            Box::pin(PendingSendFuture {
6564                _request: request,
6565                started,
6566                dropped,
6567            })
6568        }
6569    }
6570
6571    #[cfg(feature = "client")]
6572    fn pending_send_transport() -> (
6573        PendingSendTransport,
6574        tokio::sync::oneshot::Receiver<()>,
6575        tokio::sync::oneshot::Receiver<()>,
6576    ) {
6577        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
6578        let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel::<()>();
6579        let transport = PendingSendTransport {
6580            started: std::sync::Arc::new(std::sync::Mutex::new(Some(started_tx))),
6581            dropped: std::sync::Arc::new(std::sync::Mutex::new(Some(dropped_tx))),
6582        };
6583        (transport, started_rx, dropped_rx)
6584    }
6585
6586    // When the call deadline fires while the transport is still waiting for
6587    // response headers, the in-flight transport send must be dropped with the
6588    // call — nothing may keep polling it.
6589    #[cfg(feature = "client")]
6590    #[tokio::test(start_paused = true)]
6591    async fn client_stream_deadline_drops_transport_send() {
6592        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6593        use buffa_types::google::protobuf::StringValue;
6594
6595        let (transport, started_rx, dropped_rx) = pending_send_transport();
6596        let config = ClientConfig::new("http://localhost:8080".parse().unwrap());
6597
6598        let mut call = Box::pin(
6599            call_client_stream::<_, StringValue, StringValueView<'static>>(
6600                &transport,
6601                &config,
6602                "test.Service",
6603                "ClientStream",
6604                futures::stream::empty::<StringValue>(),
6605                CallOptions::default().with_timeout(Duration::from_millis(100)),
6606            ),
6607        );
6608
6609        // Drive the call until the transport send future is actually polled, so
6610        // the drop we assert below is provably the deadline path abandoning an
6611        // in-flight send rather than an unpolled future.
6612        tokio::select! {
6613            res = &mut call => panic!("call resolved before the transport was polled: {res:?}"),
6614            started = started_rx => started.expect("transport send future was never polled"),
6615        }
6616
6617        // Now let the deadline fire.
6618        let err = call
6619            .await
6620            .expect_err("deadline must fire while the transport waits for headers");
6621        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
6622
6623        // The transport send future must be dropped now that the caller has
6624        // stopped waiting.
6625        tokio::time::timeout(Duration::from_secs(5), dropped_rx)
6626            .await
6627            .expect("transport send future was not dropped after the deadline fired")
6628            .expect("drop signal sender vanished without firing");
6629    }
6630
6631    // When the caller drops the `call_client_stream` future (cancellation)
6632    // while the transport is still waiting for response headers, the in-flight
6633    // transport send must likewise be dropped. Cancellation is a distinct path
6634    // from deadline expiry — no deadline machinery fires here, so dropping the
6635    // call future must stop the in-flight send on its own.
6636    #[cfg(feature = "client")]
6637    #[tokio::test]
6638    async fn client_stream_cancellation_drops_transport_send() {
6639        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6640        use buffa_types::google::protobuf::StringValue;
6641
6642        let (transport, started_rx, dropped_rx) = pending_send_transport();
6643        let config = ClientConfig::new("http://localhost:8080".parse().unwrap());
6644
6645        let mut call = Box::pin(
6646            call_client_stream::<_, StringValue, StringValueView<'static>>(
6647                &transport,
6648                &config,
6649                "test.Service",
6650                "ClientStream",
6651                futures::stream::empty::<StringValue>(),
6652                CallOptions::default(),
6653            ),
6654        );
6655
6656        // Drive the call until the transport send future is polled, then
6657        // abandon it. `call` completing here would be a bug (the transport
6658        // never resolves), so treat that as a failure.
6659        tokio::select! {
6660            _ = &mut call => panic!("call completed though the transport never responded"),
6661            started = started_rx => started.expect("transport send future was never polled"),
6662        }
6663        drop(call);
6664
6665        tokio::time::timeout(Duration::from_secs(5), dropped_rx)
6666            .await
6667            .expect("transport send future was not dropped after caller cancellation")
6668            .expect("drop signal sender vanished without firing");
6669    }
6670
6671    // The earlier abandonment tests use an empty request stream. This one
6672    // abandons the call while the stream-backed request body still holds
6673    // unsent messages — the transport holds the request but never polls the
6674    // body. Proves an unfinished upload does not prevent the deadline from
6675    // dropping the in-flight send.
6676    #[cfg(feature = "client")]
6677    #[tokio::test(start_paused = true)]
6678    async fn client_stream_deadline_drops_send_with_unread_request_body() {
6679        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6680        use buffa_types::google::protobuf::StringValue;
6681
6682        let (transport, started_rx, dropped_rx) = pending_send_transport();
6683        let config = ClientConfig::new("http://localhost:8080".parse().unwrap());
6684
6685        // Plenty of messages the transport will never pull from the body.
6686        let requests: Vec<StringValue> = (0..256)
6687            .map(|i| StringValue::from(format!("m{i}")))
6688            .collect();
6689
6690        let mut call = Box::pin(
6691            call_client_stream::<_, StringValue, StringValueView<'static>>(
6692                &transport,
6693                &config,
6694                "test.Service",
6695                "ClientStream",
6696                stream_iter(requests),
6697                CallOptions::default().with_timeout(Duration::from_millis(100)),
6698            ),
6699        );
6700
6701        // Drive the call until the transport send future is polled: by now the
6702        // caller is parked mid-drain on channel backpressure.
6703        tokio::select! {
6704            res = &mut call => panic!("call resolved before the transport was polled: {res:?}"),
6705            started = started_rx => started.expect("transport send future was never polled"),
6706        }
6707
6708        let err = call
6709            .await
6710            .expect_err("deadline must fire while the upload is unfinished");
6711        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
6712
6713        tokio::time::timeout(Duration::from_secs(5), dropped_rx)
6714            .await
6715            .expect("transport send future was not dropped despite the unread request body")
6716            .expect("drop signal sender vanished without firing");
6717    }
6718
6719    // Success path: a well-formed Connect client-streaming response decodes
6720    // normally through the directly-awaited transport send.
6721    #[cfg(feature = "client")]
6722    #[tokio::test]
6723    async fn client_stream_returns_well_formed_response() {
6724        use buffa::Message;
6725        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6726        use buffa_types::google::protobuf::StringValue;
6727
6728        #[derive(Clone)]
6729        struct FixedResponseTransport {
6730            body: Bytes,
6731        }
6732
6733        impl ClientTransport for FixedResponseTransport {
6734            type ResponseBody = Full<Bytes>;
6735            type Error = std::io::Error;
6736
6737            fn send(
6738                &self,
6739                _request: Request<ClientBody>,
6740            ) -> BoxFuture<'static, Result<Response<Self::ResponseBody>, Self::Error>> {
6741                let body = self.body.clone();
6742                Box::pin(async move {
6743                    let response = Response::builder()
6744                        .status(http::StatusCode::OK)
6745                        .header(http::header::CONTENT_TYPE, "application/connect+proto")
6746                        .body(Full::new(body))
6747                        .unwrap();
6748                    Ok(response)
6749                })
6750            }
6751        }
6752
6753        // DATA envelope carrying the response message, then an END_STREAM
6754        // envelope with empty (`{}`) trailers — the Connect client-stream
6755        // terminus.
6756        let data =
6757            crate::envelope::Envelope::data(Bytes::from(StringValue::from("ok").encode_to_vec()))
6758                .encode();
6759        let end = crate::envelope::Envelope::end_stream(Bytes::from_static(b"{}")).encode();
6760        let mut body = BytesMut::new();
6761        body.extend_from_slice(&data);
6762        body.extend_from_slice(&end);
6763        let transport = FixedResponseTransport {
6764            body: body.freeze(),
6765        };
6766        let config = ClientConfig::new("http://localhost:8080".parse().unwrap());
6767
6768        let response = call_client_stream::<_, StringValue, StringValueView<'static>>(
6769            &transport,
6770            &config,
6771            "test.Service",
6772            "ClientStream",
6773            stream_iter([StringValue::from("req")]),
6774            CallOptions::default(),
6775        )
6776        .await
6777        .expect("well-formed client-streaming response must decode");
6778        assert_eq!(response.view().value, "ok");
6779    }
6780
6781    // A transport send failure surfaces through the
6782    // `map_transport_send_error(e, "request failed")` branch.
6783    #[cfg(feature = "client")]
6784    #[tokio::test]
6785    async fn client_stream_transport_send_error_still_surfaces() {
6786        use buffa_types::google::protobuf::__buffa::view::StringValueView;
6787        use buffa_types::google::protobuf::StringValue;
6788
6789        #[derive(Clone)]
6790        struct FailingTransport;
6791
6792        impl ClientTransport for FailingTransport {
6793            type ResponseBody = Full<Bytes>;
6794            type Error = std::io::Error;
6795
6796            fn send(
6797                &self,
6798                _request: Request<ClientBody>,
6799            ) -> BoxFuture<'static, Result<Response<Self::ResponseBody>, Self::Error>> {
6800                Box::pin(async { Err(std::io::Error::other("boom")) })
6801            }
6802        }
6803
6804        let config = ClientConfig::new("http://localhost:8080".parse().unwrap());
6805        let err = call_client_stream::<_, StringValue, StringValueView<'static>>(
6806            &FailingTransport,
6807            &config,
6808            "test.Service",
6809            "ClientStream",
6810            stream_iter([StringValue::from("req")]),
6811            CallOptions::default(),
6812        )
6813        .await
6814        .expect_err("transport send failure must surface");
6815        assert_eq!(err.code, ErrorCode::Unavailable);
6816        let message = err.message.as_deref().unwrap_or_default();
6817        assert!(message.contains("request failed"), "unexpected: {message}");
6818        assert!(message.contains("boom"), "unexpected: {message}");
6819    }
6820
6821    #[cfg(feature = "client")]
6822    #[tokio::test]
6823    async fn http_client_plaintext_rejects_https() {
6824        let client = HttpClient::plaintext();
6825        let req = Request::builder()
6826            .uri("https://localhost:8080/foo")
6827            .body(full_body(Bytes::new()))
6828            .unwrap();
6829        let err = client.send(req).await.unwrap_err();
6830        assert_eq!(err.code, ErrorCode::InvalidArgument);
6831        assert!(err.message.as_deref().unwrap().contains("with_tls"));
6832    }
6833
6834    #[cfg(all(feature = "client", feature = "client-tls"))]
6835    #[tokio::test]
6836    async fn http_client_with_tls_rejects_http() {
6837        let tls_config = std::sync::Arc::new(
6838            rustls::ClientConfig::builder()
6839                .with_root_certificates(rustls::RootCertStore::empty())
6840                .with_no_client_auth(),
6841        );
6842        let client = HttpClient::with_tls(tls_config);
6843        let req = Request::builder()
6844            .uri("http://localhost:8080/foo")
6845            .body(full_body(Bytes::new()))
6846            .unwrap();
6847        let err = client.send(req).await.unwrap_err();
6848        assert_eq!(err.code, ErrorCode::InvalidArgument);
6849        assert!(err.message.as_deref().unwrap().contains("plaintext"));
6850    }
6851
6852    #[cfg(all(feature = "client", feature = "client-tls"))]
6853    #[test]
6854    fn http_client_with_tls_construction() {
6855        // Just verify construction with a minimal config doesn't panic.
6856        // Full TLS round-trip is in tests/streaming integration tests.
6857        let tls_config = std::sync::Arc::new(
6858            rustls::ClientConfig::builder()
6859                .with_root_certificates(rustls::RootCertStore::empty())
6860                .with_no_client_auth(),
6861        );
6862        let _client = HttpClient::with_tls(tls_config);
6863    }
6864
6865    // ========================================================================
6866    // format_timeout tests
6867    // ========================================================================
6868
6869    #[test]
6870    fn test_format_timeout_connect() {
6871        assert_eq!(
6872            format_timeout(Duration::from_millis(5000), Protocol::Connect),
6873            "5000"
6874        );
6875        assert_eq!(
6876            format_timeout(Duration::from_secs(0), Protocol::Connect),
6877            "0"
6878        );
6879    }
6880
6881    #[test]
6882    fn test_format_timeout_connect_caps_at_10_digits() {
6883        // At the spec boundary (10 digits, ≈ 115 days).
6884        assert_eq!(
6885            format_timeout(Duration::from_millis(9_999_999_999), Protocol::Connect),
6886            "9999999999"
6887        );
6888        // Over the boundary → clamp at spec max. Without this, a large
6889        // Duration (e.g. from a test harness) produces an 11+ digit header
6890        // that both our own server and connect-go reject as malformed,
6891        // silently dropping the timeout.
6892        assert_eq!(
6893            format_timeout(Duration::from_secs(365 * 86400), Protocol::Connect),
6894            "9999999999"
6895        );
6896        assert_eq!(
6897            format_timeout(Duration::MAX, Protocol::Connect),
6898            "9999999999"
6899        );
6900    }
6901
6902    #[test]
6903    fn connect_huge_timeout_clamps_before_deadline() {
6904        let encoded = encoded_timeout(Duration::MAX, Protocol::Connect);
6905        assert_eq!(encoded.header_value(), "9999999999");
6906        assert_eq!(
6907            encoded.duration(),
6908            Duration::from_millis(CONNECT_TIMEOUT_MAX_MILLIS)
6909        );
6910        assert!(client_deadline(Some(Duration::MAX), Protocol::Connect).is_some());
6911    }
6912
6913    #[test]
6914    fn test_format_timeout_grpc_seconds() {
6915        assert_eq!(
6916            format_timeout(Duration::from_secs(30), Protocol::Grpc),
6917            "30S"
6918        );
6919    }
6920
6921    #[test]
6922    fn test_format_timeout_grpc_milliseconds() {
6923        assert_eq!(
6924            format_timeout(Duration::from_millis(500), Protocol::Grpc),
6925            "500m"
6926        );
6927    }
6928
6929    #[test]
6930    fn test_format_timeout_grpc_microseconds() {
6931        assert_eq!(
6932            format_timeout(Duration::from_micros(100), Protocol::Grpc),
6933            "100u"
6934        );
6935    }
6936
6937    #[test]
6938    fn test_format_timeout_grpc_nanoseconds() {
6939        assert_eq!(
6940            format_timeout(Duration::from_nanos(999), Protocol::Grpc),
6941            "999n"
6942        );
6943    }
6944
6945    #[test]
6946    fn test_format_timeout_grpc_zero() {
6947        assert_eq!(format_timeout(Duration::from_secs(0), Protocol::Grpc), "0n");
6948    }
6949
6950    #[test]
6951    fn test_format_timeout_grpc_8_digit_limit() {
6952        // 99999999 seconds fits in 8 digits
6953        assert_eq!(
6954            format_timeout(Duration::from_secs(99_999_999), Protocol::Grpc),
6955            "99999999S"
6956        );
6957        // 100000000 seconds exceeds 8 digits — truncated to seconds
6958        assert_eq!(
6959            format_timeout(Duration::from_secs(100_000_000), Protocol::Grpc),
6960            "99999999S"
6961        );
6962    }
6963
6964    #[test]
6965    fn grpc_huge_timeout_clamps_before_deadline() {
6966        let encoded = encoded_timeout(Duration::MAX, Protocol::Grpc);
6967        assert_eq!(encoded.header_value(), "99999999S");
6968        assert_eq!(
6969            encoded.duration(),
6970            Duration::from_secs(GRPC_TIMEOUT_MAX_SECONDS)
6971        );
6972        assert!(client_deadline(Some(Duration::MAX), Protocol::Grpc).is_some());
6973    }
6974
6975    #[test]
6976    fn test_format_timeout_grpc_web_same_as_grpc() {
6977        assert_eq!(
6978            format_timeout(Duration::from_millis(500), Protocol::GrpcWeb),
6979            "500m"
6980        );
6981    }
6982
6983    #[test]
6984    fn test_format_timeout_grpc_subsecond_nanosecond_residue() {
6985        // Instant arithmetic naturally produces sub-microsecond residue.
6986        // 100ms + 1ns must NOT truncate to "0S" (secs=0) — that would
6987        // tell the server the deadline already expired.
6988        assert_eq!(
6989            format_timeout(Duration::from_nanos(100_000_001), Protocol::Grpc),
6990            "100000u" // 100ms, 1ns truncated
6991        );
6992        let encoded = encoded_timeout(Duration::from_nanos(100_000_001), Protocol::Grpc);
6993        assert_eq!(encoded.duration(), Duration::from_micros(100_000));
6994        // Boundary: exactly 1ns over the 8-digit nano limit.
6995        assert_eq!(
6996            format_timeout(Duration::from_nanos(100_000_000), Protocol::Grpc),
6997            "100m" // exact millisecond, no-loss branch
6998        );
6999        // Longer duration with ns residue falls back to millis.
7000        assert_eq!(
7001            format_timeout(Duration::from_nanos(200_000_000_001), Protocol::Grpc),
7002            "200000m" // 200s, 1ns truncated; micros=200M would overflow 8 digits
7003        );
7004    }
7005
7006    // ========================================================================
7007    // grpc_percent_decode tests
7008    // ========================================================================
7009
7010    #[test]
7011    fn test_grpc_percent_decode_passthrough() {
7012        assert_eq!(grpc_percent_decode("hello world"), "hello world");
7013    }
7014
7015    #[test]
7016    fn test_grpc_percent_decode_percent() {
7017        assert_eq!(grpc_percent_decode("100%25"), "100%");
7018    }
7019
7020    #[test]
7021    fn test_grpc_percent_decode_newlines() {
7022        assert_eq!(grpc_percent_decode("a%0Ab"), "a\nb");
7023        assert_eq!(grpc_percent_decode("a%0D%0Ab"), "a\r\nb");
7024    }
7025
7026    #[test]
7027    fn test_grpc_percent_decode_utf8_multibyte() {
7028        // café encoded as percent-encoded UTF-8 bytes
7029        assert_eq!(grpc_percent_decode("caf%C3%A9"), "café");
7030        // Unicode BMP: ☺ = U+263A = E2 98 BA
7031        assert_eq!(grpc_percent_decode("%E2%98%BA"), "☺");
7032        // Non-BMP: 😈 = U+1F608 = F0 9F 98 88
7033        assert_eq!(grpc_percent_decode("%F0%9F%98%88"), "😈");
7034    }
7035
7036    #[test]
7037    fn test_grpc_percent_decode_partial_percent() {
7038        // Incomplete percent sequences are passed through
7039        assert_eq!(grpc_percent_decode("100%"), "100%");
7040        assert_eq!(grpc_percent_decode("a%2"), "a%2");
7041    }
7042
7043    #[test]
7044    fn test_grpc_percent_decode_invalid_hex() {
7045        assert_eq!(grpc_percent_decode("a%ZZb"), "a%ZZb");
7046    }
7047
7048    // ========================================================================
7049    // parse_grpc_error_from_trailers tests
7050    // ========================================================================
7051
7052    #[test]
7053    fn test_parse_grpc_error_ok_returns_none() {
7054        let mut trailers = http::HeaderMap::new();
7055        trailers.insert("grpc-status", http::HeaderValue::from_static("0"));
7056        assert!(parse_grpc_error_from_trailers(&trailers).is_none());
7057    }
7058
7059    #[test]
7060    fn test_parse_grpc_error_missing_status_returns_none() {
7061        let trailers = http::HeaderMap::new();
7062        assert!(parse_grpc_error_from_trailers(&trailers).is_none());
7063    }
7064
7065    #[test]
7066    fn test_parse_grpc_error_with_code_and_message() {
7067        let mut trailers = http::HeaderMap::new();
7068        trailers.insert("grpc-status", http::HeaderValue::from_static("5"));
7069        trailers.insert(
7070            "grpc-message",
7071            http::HeaderValue::from_static("not%20found"),
7072        );
7073        let err = parse_grpc_error_from_trailers(&trailers).unwrap();
7074        assert_eq!(err.code, ErrorCode::NotFound);
7075        assert_eq!(err.message.as_deref(), Some("not found"));
7076    }
7077
7078    #[test]
7079    fn test_parse_grpc_error_unknown_code() {
7080        let mut trailers = http::HeaderMap::new();
7081        trailers.insert("grpc-status", http::HeaderValue::from_static("99"));
7082        let err = parse_grpc_error_from_trailers(&trailers).unwrap();
7083        assert_eq!(err.code, ErrorCode::Unknown);
7084    }
7085
7086    #[test]
7087    fn test_parse_grpc_error_custom_trailers() {
7088        let mut trailers = http::HeaderMap::new();
7089        trailers.insert("grpc-status", http::HeaderValue::from_static("13"));
7090        trailers.insert("x-custom", http::HeaderValue::from_static("value"));
7091        let err = parse_grpc_error_from_trailers(&trailers).unwrap();
7092        assert_eq!(err.code, ErrorCode::Internal);
7093        assert_eq!(
7094            err.trailers().get("x-custom").unwrap().to_str().unwrap(),
7095            "value"
7096        );
7097    }
7098
7099    // grpc_status encode/decode tests are in the grpc_status module
7100
7101    // ========================================================================
7102    // parse_grpc_web_trailer_frame_with_compression tests
7103    // ========================================================================
7104
7105    #[test]
7106    fn test_parse_grpc_web_trailer_uncompressed() {
7107        let payload = b"grpc-status: 0\r\n";
7108        let mut frame = Vec::with_capacity(5 + payload.len());
7109        frame.push(0x80);
7110        frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
7111        frame.extend_from_slice(payload);
7112
7113        let headers = parse_grpc_web_trailer_frame_with_compression(&frame, None).unwrap();
7114        assert_eq!(headers.get("grpc-status").unwrap().to_str().unwrap(), "0");
7115    }
7116
7117    #[test]
7118    fn test_parse_grpc_web_trailer_with_error() {
7119        let payload = b"grpc-status: 13\r\ngrpc-message: internal error\r\n";
7120        let mut frame = Vec::with_capacity(5 + payload.len());
7121        frame.push(0x80);
7122        frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
7123        frame.extend_from_slice(payload);
7124
7125        let headers = parse_grpc_web_trailer_frame_with_compression(&frame, None).unwrap();
7126        assert_eq!(headers.get("grpc-status").unwrap().to_str().unwrap(), "13");
7127        assert_eq!(
7128            headers.get("grpc-message").unwrap().to_str().unwrap(),
7129            "internal error"
7130        );
7131    }
7132
7133    #[test]
7134    fn test_parse_grpc_web_trailer_truncated() {
7135        // Less than 5 bytes
7136        assert!(parse_grpc_web_trailer_frame_with_compression(&[0x80, 0, 0], None).is_none());
7137    }
7138
7139    #[test]
7140    fn test_parse_grpc_web_trailer_not_trailer() {
7141        // Flag byte 0x00 — not a trailer
7142        let frame = [0x00, 0, 0, 0, 5, b'h', b'e', b'l', b'l', b'o'];
7143        assert!(parse_grpc_web_trailer_frame_with_compression(&frame, None).is_none());
7144    }
7145
7146    #[test]
7147    fn test_parse_grpc_web_trailer_compressed_no_registry() {
7148        // Flag 0x81 (compressed trailer) but no compression registry
7149        let payload = b"grpc-status: 0\r\n";
7150        let mut frame = Vec::with_capacity(5 + payload.len());
7151        frame.push(0x81);
7152        frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
7153        frame.extend_from_slice(payload);
7154        // Without compression registry, should return None
7155        assert!(parse_grpc_web_trailer_frame_with_compression(&frame, None).is_none());
7156    }
7157
7158    #[test]
7159    fn test_parse_grpc_web_trailer_floods_do_not_panic() {
7160        // A hostile server can pack far more distinct trailer names than
7161        // `HeaderMap` can hold (`MAX_SIZE = 1 << 15 = 32_768`) into a payload
7162        // that stays under the 1 MiB byte cap. Each `hN:` line is short, so
7163        // 40_000 distinct names is only ~300 KiB. The parser must not panic.
7164        let mut payload = String::new();
7165        for i in 0..40_000u32 {
7166            payload.push_str(&format!("h{i}:\n"));
7167        }
7168        let payload = payload.into_bytes();
7169        assert!(
7170            payload.len() < 1024 * 1024,
7171            "payload must stay under byte cap"
7172        );
7173
7174        let mut frame = Vec::with_capacity(5 + payload.len());
7175        frame.push(0x80);
7176        frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
7177        frame.extend_from_slice(&payload);
7178
7179        // Before the fix this panicked with "size overflows MAX_SIZE".
7180        let headers = parse_grpc_web_trailer_frame_with_compression(&frame, None)
7181            .expect("flood frame is well-formed and should parse");
7182        // The map fills up to the type's hard ceiling and stops: it accepts
7183        // entries (the loop didn't drop everything) but never exceeds the cap.
7184        assert!(headers.keys_len() > 0, "early trailers should be retained");
7185        assert!(headers.keys_len() <= 1 << 15);
7186    }
7187
7188    #[test]
7189    fn test_append_metadata_capped_copies_entries() {
7190        let mut metadata = HashMap::new();
7191        metadata.insert("grpc-status".to_string(), vec!["0".to_string()]);
7192        metadata.insert(
7193            "x-custom".to_string(),
7194            vec!["a".to_string(), "b".to_string()],
7195        );
7196        let mut trailers = http::HeaderMap::new();
7197        append_metadata_capped(&mut trailers, metadata);
7198        assert_eq!(trailers.get("grpc-status").unwrap(), "0");
7199        assert_eq!(trailers.get_all("x-custom").iter().count(), 2);
7200    }
7201
7202    #[test]
7203    fn test_append_metadata_capped_does_not_panic_on_flood() {
7204        // A Connect end-stream `metadata` map is deserialized from server JSON,
7205        // so its key count is attacker-controlled. Feeding more distinct keys
7206        // than the `HeaderMap` ceiling (`MAX_SIZE = 1 << 15`) must cap rather
7207        // than panic with "size overflows MAX_SIZE".
7208        let mut metadata = HashMap::new();
7209        for i in 0..40_000u32 {
7210            metadata.insert(format!("h{i}"), vec![String::new()]);
7211        }
7212        let mut trailers = http::HeaderMap::new();
7213        append_metadata_capped(&mut trailers, metadata);
7214        assert!(trailers.keys_len() > 0, "early entries should be retained");
7215        assert!(trailers.keys_len() <= 1 << 15);
7216    }
7217
7218    #[test]
7219    fn test_parse_grpc_web_trailer_newline_only() {
7220        // Some implementations use \n instead of \r\n
7221        let payload = b"grpc-status: 0\n";
7222        let mut frame = Vec::with_capacity(5 + payload.len());
7223        frame.push(0x80);
7224        frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
7225        frame.extend_from_slice(payload);
7226
7227        let headers = parse_grpc_web_trailer_frame_with_compression(&frame, None).unwrap();
7228        assert_eq!(headers.get("grpc-status").unwrap().to_str().unwrap(), "0");
7229    }
7230
7231    #[tokio::test]
7232    async fn grpc_unary_rejects_second_message_before_decompression() {
7233        use buffa_types::google::protobuf::__buffa::view::StringValueView;
7234
7235        let mut body = BytesMut::new();
7236        body.extend_from_slice(&Envelope::data(Bytes::new()).encode());
7237        body.extend_from_slice(&Envelope::compressed(Bytes::from_static(b"not-gzip")).encode());
7238
7239        let response = Response::builder()
7240            .header(http::header::CONTENT_TYPE, "application/grpc+proto")
7241            .body(Full::new(body.freeze()))
7242            .unwrap();
7243        let config =
7244            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7245
7246        let err = parse_grpc_unary_response::<_, StringValueView<'static>>(
7247            response,
7248            &config,
7249            &CallOptions::default(),
7250            None,
7251        )
7252        .await
7253        .unwrap_err();
7254        assert_eq!(err.code, ErrorCode::Unimplemented);
7255        assert_eq!(
7256            err.message.as_deref(),
7257            Some("received multiple response messages where exactly one was expected")
7258        );
7259    }
7260
7261    #[tokio::test]
7262    async fn grpc_web_unary_stops_reading_after_trailer_frame() {
7263        use buffa_types::google::protobuf::__buffa::view::StringValueView;
7264
7265        let mut body = BytesMut::new();
7266        body.extend_from_slice(&Envelope::data(Bytes::from_static(b"\x0a\x02hi")).encode());
7267        let trailer_payload = b"grpc-status: 0\r\n";
7268        body.extend_from_slice(&[0x80]);
7269        body.extend_from_slice(&(trailer_payload.len() as u32).to_be_bytes());
7270        body.extend_from_slice(trailer_payload);
7271
7272        let (tx, rx) = tokio::sync::mpsc::channel(2);
7273        tx.send(Ok(body.freeze())).await.unwrap();
7274        tx.send(Ok(Bytes::from_static(b"server is still writing")))
7275            .await
7276            .unwrap();
7277
7278        // Keep the sender alive: a complete trailers frame must finish the
7279        // response without waiting for EOF or consuming the queued bytes.
7280        let response = Response::builder()
7281            .header(http::header::CONTENT_TYPE, "application/grpc-web+proto")
7282            .body(ChannelBody { rx })
7283            .unwrap();
7284        let config =
7285            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::GrpcWeb);
7286
7287        let response = tokio::time::timeout(
7288            Duration::from_secs(1),
7289            parse_grpc_unary_response::<_, StringValueView<'static>>(
7290                response,
7291                &config,
7292                &CallOptions::default(),
7293                None,
7294            ),
7295        )
7296        .await
7297        .expect("parser should stop after the gRPC-Web trailer frame")
7298        .unwrap();
7299        assert_eq!(
7300            response.trailers().get("grpc-status").unwrap(),
7301            http::HeaderValue::from_static("0")
7302        );
7303    }
7304
7305    #[tokio::test]
7306    async fn grpc_unary_accepts_bare_application_grpc_with_parameters() {
7307        use buffa::Message;
7308        use buffa_types::google::protobuf::__buffa::view::StringValueView;
7309        use buffa_types::google::protobuf::StringValue;
7310        use http_body::Frame;
7311        use http_body_util::StreamBody;
7312
7313        let data = Envelope::data(StringValue::from("hi").encode_to_bytes()).encode();
7314        let mut trailers = http::HeaderMap::new();
7315        trailers.insert("grpc-status", "0".parse().unwrap());
7316        let frames: Vec<Result<Frame<Bytes>, std::convert::Infallible>> =
7317            vec![Ok(Frame::data(data)), Ok(Frame::trailers(trailers))];
7318
7319        let response = Response::builder()
7320            .header(
7321                http::header::CONTENT_TYPE,
7322                "application/grpc; charset=utf-8",
7323            )
7324            .body(StreamBody::new(futures::stream::iter(frames)))
7325            .unwrap();
7326        let config =
7327            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7328
7329        let response = parse_grpc_unary_response::<_, StringValueView<'static>>(
7330            response,
7331            &config,
7332            &CallOptions::default(),
7333            None,
7334        )
7335        .await
7336        .expect("bare application/grpc must be accepted as proto");
7337        assert_eq!(response.view().value, "hi");
7338        assert_eq!(response.trailers().get("grpc-status").unwrap(), "0");
7339    }
7340
7341    #[tokio::test]
7342    async fn grpc_unary_rejects_grpc_web_content_type() {
7343        use buffa_types::google::protobuf::__buffa::view::StringValueView;
7344
7345        let response = Response::builder()
7346            .header(http::header::CONTENT_TYPE, "application/grpc-web+proto")
7347            .body(Full::new(Bytes::new()))
7348            .unwrap();
7349        let config =
7350            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7351
7352        let err = parse_grpc_unary_response::<_, StringValueView<'static>>(
7353            response,
7354            &config,
7355            &CallOptions::default(),
7356            None,
7357        )
7358        .await
7359        .expect_err("gRPC client must reject gRPC-Web content type");
7360        // Cross-family mismatches are `unknown` (not a gRPC response at all),
7361        // matching connect-go; only same-family codec mismatches are
7362        // `internal`.
7363        assert_eq!(err.code, ErrorCode::Unknown);
7364        assert_eq!(
7365            err.message.as_deref(),
7366            Some(
7367                "unexpected content-type: application/grpc-web+proto (expected application/grpc+proto)"
7368            )
7369        );
7370    }
7371
7372    #[tokio::test]
7373    async fn grpc_unary_rejects_mismatched_codec_content_type() {
7374        use buffa_types::google::protobuf::__buffa::view::StringValueView;
7375
7376        let response = Response::builder()
7377            .header(http::header::CONTENT_TYPE, "application/grpc+json")
7378            .body(Full::new(Bytes::new()))
7379            .unwrap();
7380        let config =
7381            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7382
7383        let err = parse_grpc_unary_response::<_, StringValueView<'static>>(
7384            response,
7385            &config,
7386            &CallOptions::default(),
7387            None,
7388        )
7389        .await
7390        .expect_err("gRPC client must reject mismatched response codec");
7391        assert_eq!(err.code, ErrorCode::Internal);
7392        assert_eq!(
7393            err.message.as_deref(),
7394            Some(
7395                "unexpected content-type: application/grpc+json (expected application/grpc+proto)"
7396            )
7397        );
7398    }
7399
7400    #[test]
7401    fn grpc_response_content_type_rejects_non_grpc_as_unknown() {
7402        let mut headers = http::HeaderMap::new();
7403        headers.insert(http::header::CONTENT_TYPE, "text/html".parse().unwrap());
7404        let config =
7405            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7406
7407        let err = validate_grpc_response_content_type(&headers, &config)
7408            .expect_err("non-gRPC content type must be rejected");
7409        assert_eq!(err.code, ErrorCode::Unknown);
7410        assert_eq!(
7411            err.message.as_deref(),
7412            Some("unexpected content-type: text/html (expected application/grpc+proto)")
7413        );
7414        assert!(
7415            err.response_headers()
7416                .contains_key(http::header::CONTENT_TYPE)
7417        );
7418    }
7419
7420    #[test]
7421    fn grpc_response_content_type_accepts_missing_header() {
7422        let config =
7423            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7424        validate_grpc_response_content_type(&http::HeaderMap::new(), &config)
7425            .expect("missing content-type must remain accepted");
7426    }
7427
7428    #[test]
7429    fn grpc_response_content_type_accepts_bare_for_json_codec() {
7430        // Proxies that synthesize trailers-only error replies (e.g. Envoy)
7431        // send bare `application/grpc` regardless of the request subtype, so
7432        // the bare type must be accepted for every codec, as in connect-go.
7433        let mut headers = http::HeaderMap::new();
7434        headers.insert(
7435            http::header::CONTENT_TYPE,
7436            "application/grpc".parse().unwrap(),
7437        );
7438        let config = ClientConfig::new("http://localhost".parse().unwrap())
7439            .with_protocol(Protocol::Grpc)
7440            .with_codec_format(CodecFormat::Json);
7441        validate_grpc_response_content_type(&headers, &config)
7442            .expect("bare application/grpc must be accepted for a json-codec client");
7443    }
7444
7445    #[test]
7446    fn grpc_web_response_content_type_accepts_bare() {
7447        let mut headers = http::HeaderMap::new();
7448        headers.insert(
7449            http::header::CONTENT_TYPE,
7450            "application/grpc-web".parse().unwrap(),
7451        );
7452        let config =
7453            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::GrpcWeb);
7454        validate_grpc_response_content_type(&headers, &config)
7455            .expect("bare application/grpc-web must be accepted as proto");
7456    }
7457
7458    #[test]
7459    fn grpc_web_response_content_type_rejects_grpc_as_unknown() {
7460        let mut headers = http::HeaderMap::new();
7461        headers.insert(
7462            http::header::CONTENT_TYPE,
7463            "application/grpc+proto".parse().unwrap(),
7464        );
7465        let config =
7466            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::GrpcWeb);
7467
7468        let err = validate_grpc_response_content_type(&headers, &config)
7469            .expect_err("gRPC-Web client must reject plain gRPC content type");
7470        assert_eq!(err.code, ErrorCode::Unknown);
7471        assert_eq!(
7472            err.message.as_deref(),
7473            Some(
7474                "unexpected content-type: application/grpc+proto (expected application/grpc-web+proto)"
7475            )
7476        );
7477    }
7478
7479    // ========================================================================
7480    // Content type helper tests
7481    // ========================================================================
7482
7483    #[test]
7484    fn test_unary_request_content_type_connect() {
7485        let config = ClientConfig::new("http://localhost".parse().unwrap());
7486        assert_eq!(unary_request_content_type(&config), "application/proto");
7487
7488        let config = config.with_codec_format(CodecFormat::Json);
7489        assert_eq!(unary_request_content_type(&config), "application/json");
7490    }
7491
7492    #[cfg(not(feature = "json"))]
7493    #[test]
7494    fn decode_response_view_json_is_unimplemented_without_feature() {
7495        use buffa::Message;
7496        use buffa_types::google::protobuf::__buffa::view::StringValueView;
7497        use buffa_types::google::protobuf::StringValue;
7498        // Proto-only client: the response-decode JSON arm is compiled out and
7499        // surfaces `Unimplemented` instead of attempting serde. The
7500        // request-encode paths are the symmetric `return Err(Unimplemented)`
7501        // guards that fire before any transport I/O.
7502        let err = decode_response_view::<StringValueView>(
7503            Bytes::from_static(b"\"x\""),
7504            CodecFormat::Json,
7505        )
7506        .unwrap_err();
7507        assert_eq!(err.code, ErrorCode::Unimplemented);
7508
7509        // Proto decoding still works.
7510        let bytes = StringValue::from("ok").encode_to_bytes();
7511        assert!(decode_response_view::<StringValueView>(bytes, CodecFormat::Proto).is_ok());
7512    }
7513
7514    #[test]
7515    fn test_unary_request_content_type_grpc() {
7516        let config =
7517            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7518        assert_eq!(
7519            unary_request_content_type(&config),
7520            "application/grpc+proto"
7521        );
7522
7523        let config = config.with_codec_format(CodecFormat::Json);
7524        assert_eq!(unary_request_content_type(&config), "application/grpc+json");
7525    }
7526
7527    #[test]
7528    fn test_streaming_request_content_type() {
7529        let config = ClientConfig::new("http://localhost".parse().unwrap());
7530        assert_eq!(
7531            streaming_request_content_type(&config),
7532            "application/connect+proto"
7533        );
7534
7535        let config = config.with_protocol(Protocol::Grpc);
7536        assert_eq!(
7537            streaming_request_content_type(&config),
7538            "application/grpc+proto"
7539        );
7540
7541        let config = config.with_protocol(Protocol::GrpcWeb);
7542        assert_eq!(
7543            streaming_request_content_type(&config),
7544            "application/grpc-web+proto"
7545        );
7546    }
7547
7548    // ========================================================================
7549    // http_status_to_error_code tests
7550    // ========================================================================
7551
7552    #[test]
7553    fn test_http_status_to_error_code() {
7554        assert_eq!(
7555            http_status_to_error_code(http::StatusCode::BAD_REQUEST),
7556            ErrorCode::Internal
7557        );
7558        assert_eq!(
7559            http_status_to_error_code(http::StatusCode::UNAUTHORIZED),
7560            ErrorCode::Unauthenticated
7561        );
7562        assert_eq!(
7563            http_status_to_error_code(http::StatusCode::FORBIDDEN),
7564            ErrorCode::PermissionDenied
7565        );
7566        assert_eq!(
7567            http_status_to_error_code(http::StatusCode::NOT_FOUND),
7568            ErrorCode::Unimplemented
7569        );
7570        assert_eq!(
7571            http_status_to_error_code(http::StatusCode::SERVICE_UNAVAILABLE),
7572            ErrorCode::Unavailable
7573        );
7574        assert_eq!(
7575            http_status_to_error_code(http::StatusCode::INTERNAL_SERVER_ERROR),
7576            ErrorCode::Unknown
7577        );
7578    }
7579
7580    // ========================================================================
7581    // add_unary_request_headers tests
7582    // ========================================================================
7583
7584    #[test]
7585    fn test_add_unary_request_headers_connect() {
7586        let config = ClientConfig::new("http://localhost".parse().unwrap());
7587        let builder = http::Request::builder();
7588        let builder = add_unary_request_headers(builder, &config, None, None);
7589        let req = builder.body(()).unwrap();
7590        assert_eq!(
7591            req.headers().get("content-type").unwrap(),
7592            "application/proto"
7593        );
7594        assert_eq!(req.headers().get("connect-protocol-version").unwrap(), "1");
7595        assert!(req.headers().get("te").is_none());
7596    }
7597
7598    #[test]
7599    fn test_add_unary_request_headers_grpc() {
7600        let config =
7601            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7602        let builder = http::Request::builder();
7603        let builder = add_unary_request_headers(builder, &config, None, None);
7604        let req = builder.body(()).unwrap();
7605        assert_eq!(
7606            req.headers().get("content-type").unwrap(),
7607            "application/grpc+proto"
7608        );
7609        assert_eq!(req.headers().get("te").unwrap(), "trailers");
7610        assert!(req.headers().get("connect-protocol-version").is_none());
7611    }
7612
7613    #[test]
7614    fn test_add_unary_request_headers_grpc_web() {
7615        let config =
7616            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::GrpcWeb);
7617        let builder = http::Request::builder();
7618        let builder = add_unary_request_headers(builder, &config, None, None);
7619        let req = builder.body(()).unwrap();
7620        assert_eq!(
7621            req.headers().get("content-type").unwrap(),
7622            "application/grpc-web+proto"
7623        );
7624        assert!(req.headers().get("te").is_none());
7625        assert!(req.headers().get("connect-protocol-version").is_none());
7626    }
7627
7628    #[test]
7629    fn test_add_unary_request_headers_with_timeout() {
7630        let config =
7631            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7632        let builder = http::Request::builder();
7633        let builder =
7634            add_unary_request_headers(builder, &config, Some(Duration::from_millis(500)), None);
7635        let req = builder.body(()).unwrap();
7636        assert_eq!(req.headers().get("grpc-timeout").unwrap(), "500m");
7637    }
7638
7639    // ========================================================================
7640    // with_deadline (client-side timeout enforcement)
7641    // ========================================================================
7642
7643    #[tokio::test]
7644    async fn with_deadline_none_passes_through() {
7645        let result: Result<i32, ConnectError> = with_deadline(None, async { Ok(42) }).await;
7646        assert_eq!(result.unwrap(), 42);
7647    }
7648
7649    #[tokio::test]
7650    async fn with_deadline_completes_before_deadline() {
7651        let deadline = std::time::Instant::now() + Duration::from_secs(10);
7652        let result: Result<i32, ConnectError> =
7653            with_deadline(Some(deadline), async { Ok(42) }).await;
7654        assert_eq!(result.unwrap(), 42);
7655    }
7656
7657    #[tokio::test(start_paused = true)]
7658    async fn with_deadline_fires_on_slow_future() {
7659        let deadline = std::time::Instant::now() + Duration::from_millis(100);
7660        let slow = async {
7661            tokio::time::sleep(Duration::from_secs(10)).await;
7662            Ok::<i32, ConnectError>(42)
7663        };
7664        let result = with_deadline(Some(deadline), slow).await;
7665        let err = result.unwrap_err();
7666        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
7667    }
7668
7669    #[tokio::test(start_paused = true)]
7670    async fn with_deadline_already_passed_returns_immediately() {
7671        // Deadline in the past — should return DeadlineExceeded without
7672        // polling the future (or at most once).
7673        let deadline = std::time::Instant::now() - Duration::from_secs(1);
7674        let result: Result<i32, ConnectError> =
7675            with_deadline(Some(deadline), std::future::pending()).await;
7676        let err = result.unwrap_err();
7677        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
7678    }
7679
7680    #[tokio::test]
7681    async fn with_deadline_propagates_inner_error() {
7682        let deadline = std::time::Instant::now() + Duration::from_secs(10);
7683        let failing = async { Err::<i32, _>(ConnectError::internal("inner")) };
7684        let result = with_deadline(Some(deadline), failing).await;
7685        let err = result.unwrap_err();
7686        assert_eq!(err.code, ErrorCode::Internal);
7687    }
7688
7689    // ========================================================================
7690    // ChannelBody (bidi request body)
7691    // ========================================================================
7692
7693    #[tokio::test]
7694    async fn channel_body_delivers_frames_then_eof() {
7695        let (tx, rx) = tokio::sync::mpsc::channel(4);
7696        let body = ChannelBody { rx };
7697
7698        tx.send(Ok(Bytes::from_static(b"hello"))).await.unwrap();
7699        tx.send(Ok(Bytes::from_static(b"world"))).await.unwrap();
7700        drop(tx); // close send side
7701
7702        let collected = body.collect().await.unwrap().to_bytes();
7703        assert_eq!(&collected[..], b"helloworld");
7704    }
7705
7706    #[tokio::test]
7707    async fn channel_body_surfaces_error() {
7708        let (tx, rx) = tokio::sync::mpsc::channel(4);
7709        let mut body = ChannelBody { rx };
7710
7711        tx.send(Err(ConnectError::internal("boom"))).await.unwrap();
7712        drop(tx);
7713
7714        let frame = std::future::poll_fn(|cx| Pin::new(&mut body).poll_frame(cx)).await;
7715        assert!(matches!(frame, Some(Err(_))));
7716    }
7717
7718    // ========================================================================
7719    // collect_body_bounded
7720    // ========================================================================
7721
7722    #[tokio::test]
7723    async fn collect_body_bounded_within_limit() {
7724        let body = Full::new(Bytes::from_static(b"hello"));
7725        let got = collect_body_bounded(body, 10, None).await.unwrap();
7726        assert_eq!(&got[..], b"hello");
7727    }
7728
7729    #[tokio::test]
7730    async fn collect_body_bounded_at_exact_limit() {
7731        let body = Full::new(Bytes::from_static(b"hello"));
7732        let got = collect_body_bounded(body, 5, None).await.unwrap();
7733        assert_eq!(&got[..], b"hello");
7734    }
7735
7736    #[tokio::test]
7737    async fn collect_body_bounded_exceeds_limit() {
7738        let body = Full::new(Bytes::from_static(b"hello world"));
7739        let err = collect_body_bounded(body, 5, None).await.unwrap_err();
7740        assert_eq!(err.code, ErrorCode::ResourceExhausted);
7741    }
7742
7743    #[tokio::test]
7744    async fn collect_body_bounded_empty() {
7745        let body = Full::new(Bytes::new());
7746        let got = collect_body_bounded(body, 0, None).await.unwrap();
7747        assert!(got.is_empty());
7748    }
7749
7750    #[tokio::test]
7751    async fn collect_body_bounded_multi_frame_exceeds_mid_stream() {
7752        let (tx, rx) = tokio::sync::mpsc::channel(4);
7753        let body = ChannelBody { rx };
7754        tx.send(Ok(Bytes::from_static(b"aaa"))).await.unwrap();
7755        tx.send(Ok(Bytes::from_static(b"bbb"))).await.unwrap();
7756        tx.send(Ok(Bytes::from_static(b"ccc"))).await.unwrap();
7757        drop(tx);
7758        // limit 7: first two frames (6 bytes) fit, third (3 more → 9) exceeds
7759        let err = collect_body_bounded(body, 7, None).await.unwrap_err();
7760        assert_eq!(err.code, ErrorCode::ResourceExhausted);
7761    }
7762
7763    #[tokio::test]
7764    async fn collect_body_bounded_multi_frame_within_limit() {
7765        let (tx, rx) = tokio::sync::mpsc::channel(4);
7766        let body = ChannelBody { rx };
7767        tx.send(Ok(Bytes::from_static(b"foo"))).await.unwrap();
7768        tx.send(Ok(Bytes::from_static(b"bar"))).await.unwrap();
7769        drop(tx);
7770        let got = collect_body_bounded(body, 10, None).await.unwrap();
7771        assert_eq!(&got[..], b"foobar");
7772    }
7773
7774    #[tokio::test]
7775    async fn collect_body_bounded_propagates_body_error() {
7776        let (tx, rx) = tokio::sync::mpsc::channel(4);
7777        let body = ChannelBody { rx };
7778        tx.send(Err(ConnectError::internal("io"))).await.unwrap();
7779        drop(tx);
7780        let err = collect_body_bounded(body, 1024, None).await.unwrap_err();
7781        assert_eq!(err.code, ErrorCode::Internal);
7782    }
7783
7784    /// The race this fixes: a server enforcing the same deadline aborts the
7785    /// stream, and its RST_STREAM can arrive before the local timer fires.
7786    /// The body read then fails first, and the caller sees a transport fault
7787    /// for what is really a timeout.
7788    #[tokio::test]
7789    async fn a_body_error_after_the_deadline_is_deadline_exceeded() {
7790        let (tx, rx) = tokio::sync::mpsc::channel(4);
7791        let body = ChannelBody { rx };
7792        tx.send(Err(ConnectError::internal("io"))).await.unwrap();
7793        drop(tx);
7794
7795        let elapsed = std::time::Instant::now() - Duration::from_millis(1);
7796        let err = collect_body_bounded(body, 1024, Some(elapsed))
7797            .await
7798            .unwrap_err();
7799        assert_eq!(err.code, ErrorCode::DeadlineExceeded);
7800        // The transport cause survives the reclassification: a genuine
7801        // transport fault that merely happened after the deadline is still
7802        // diagnosable.
7803        assert!(
7804            err.message.as_deref().unwrap_or_default().contains("io"),
7805            "got {:?}",
7806            err.message
7807        );
7808    }
7809
7810    /// The other half, and the reason this is a deadline check rather than a
7811    /// blanket remap: with the deadline still in the future the same failure
7812    /// is a real transport error and must stay `internal`.
7813    #[tokio::test]
7814    async fn a_body_error_before_the_deadline_stays_internal() {
7815        let (tx, rx) = tokio::sync::mpsc::channel(4);
7816        let body = ChannelBody { rx };
7817        tx.send(Err(ConnectError::internal("io"))).await.unwrap();
7818        drop(tx);
7819
7820        let future = std::time::Instant::now() + Duration::from_secs(60);
7821        let err = collect_body_bounded(body, 1024, Some(future))
7822            .await
7823            .unwrap_err();
7824        assert_eq!(err.code, ErrorCode::Internal);
7825    }
7826
7827    /// A call with no deadline can never be past one, and must not pick up
7828    /// the timeout wording either — `collect_body_bounded_propagates_body_error`
7829    /// already covers the code, so this covers the message.
7830    #[tokio::test]
7831    async fn a_body_error_without_a_deadline_is_not_described_as_a_timeout() {
7832        let (tx, rx) = tokio::sync::mpsc::channel(4);
7833        let body = ChannelBody { rx };
7834        tx.send(Err(ConnectError::internal("io"))).await.unwrap();
7835        drop(tx);
7836
7837        let err = collect_body_bounded(body, 1024, None).await.unwrap_err();
7838        assert_eq!(err.code, ErrorCode::Internal);
7839        assert!(
7840            !err.message
7841                .as_deref()
7842                .unwrap_or_default()
7843                .contains("deadline"),
7844            "got {:?}",
7845            err.message
7846        );
7847    }
7848
7849    #[test]
7850    fn test_add_streaming_request_headers_grpc() {
7851        let config =
7852            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7853        let builder = http::Request::builder();
7854        let builder = add_streaming_request_headers(builder, &config, None);
7855        let req = builder.body(()).unwrap();
7856        assert_eq!(
7857            req.headers().get("content-type").unwrap(),
7858            "application/grpc+proto"
7859        );
7860        assert_eq!(req.headers().get("te").unwrap(), "trailers");
7861    }
7862
7863    // ========================================================================
7864    // ClientConfig builder tests
7865    // ========================================================================
7866
7867    #[test]
7868    fn test_client_config_protocol() {
7869        let config =
7870            ClientConfig::new("http://localhost".parse().unwrap()).with_protocol(Protocol::Grpc);
7871        assert_eq!(config.protocol, Protocol::Grpc);
7872    }
7873
7874    #[test]
7875    fn test_client_config_default_protocol() {
7876        let config = ClientConfig::new("http://localhost".parse().unwrap());
7877        assert_eq!(config.protocol, Protocol::Connect);
7878    }
7879
7880    // ========================================================================
7881    // add_unary_request_headers — Content-Encoding only when actually compressed
7882    // ========================================================================
7883
7884    fn headers_for(protocol: Protocol, applied_encoding: Option<&str>) -> http::HeaderMap {
7885        let config = ClientConfig::new("http://localhost".parse().unwrap())
7886            .with_protocol(protocol)
7887            .compress_requests("gzip");
7888        let builder = http::Request::builder();
7889        add_unary_request_headers(builder, &config, None, applied_encoding)
7890            .body(())
7891            .unwrap()
7892            .headers()
7893            .clone()
7894    }
7895
7896    #[test]
7897    fn connect_unary_no_content_encoding_when_compression_skipped() {
7898        // Compression policy skipped this small body → NO Content-Encoding header.
7899        let headers = headers_for(Protocol::Connect, None);
7900        assert!(
7901            !headers.contains_key(http::header::CONTENT_ENCODING),
7902            "Content-Encoding must not be set when compression policy skipped the body"
7903        );
7904    }
7905
7906    #[test]
7907    fn connect_unary_content_encoding_when_compressed() {
7908        let headers = headers_for(Protocol::Connect, Some("gzip"));
7909        assert_eq!(headers.get(http::header::CONTENT_ENCODING).unwrap(), "gzip");
7910    }
7911
7912    #[test]
7913    fn grpc_unary_encoding_header_independent_of_applied() {
7914        // gRPC's grpc-encoding declares the algorithm used WHEN the envelope
7915        // flag is set — it's fine to send even if the policy didn't compress
7916        // this particular message. It's driven by config, not applied.
7917        let headers = headers_for(Protocol::Grpc, None);
7918        assert_eq!(headers.get("grpc-encoding").unwrap(), "gzip");
7919    }
7920
7921    // ========================================================================
7922    // effective_options + merge_headers
7923    // ========================================================================
7924
7925    fn test_config() -> ClientConfig {
7926        ClientConfig::new("http://localhost:8080".parse().unwrap())
7927    }
7928
7929    #[test]
7930    fn effective_options_uses_config_defaults_when_options_unset() {
7931        let config = test_config()
7932            .with_default_timeout(Duration::from_secs(30))
7933            .with_default_max_message_size(1024)
7934            .with_default_header("x-trace-id", "cfg-trace");
7935
7936        let eff = effective_options(&config, CallOptions::default());
7937
7938        assert_eq!(eff.timeout, Some(Duration::from_secs(30)));
7939        assert_eq!(eff.max_message_size, Some(1024));
7940        assert_eq!(eff.headers.get("x-trace-id").unwrap(), "cfg-trace");
7941    }
7942
7943    #[test]
7944    fn effective_options_options_override_config_defaults() {
7945        let config = test_config()
7946            .with_default_timeout(Duration::from_secs(30))
7947            .with_default_max_message_size(1024);
7948
7949        let options = CallOptions::default()
7950            .with_timeout(Duration::from_secs(5))
7951            .with_max_message_size(512);
7952
7953        let eff = effective_options(&config, options);
7954
7955        assert_eq!(eff.timeout, Some(Duration::from_secs(5)));
7956        assert_eq!(eff.max_message_size, Some(512));
7957    }
7958
7959    #[test]
7960    fn effective_options_compress_has_no_config_default() {
7961        let config = test_config();
7962        let options = CallOptions::default().with_compress(true);
7963        let eff = effective_options(&config, options);
7964        assert_eq!(eff.compress, Some(true));
7965    }
7966
7967    #[test]
7968    fn merge_headers_options_override_config_same_name() {
7969        let mut cfg = http::HeaderMap::new();
7970        cfg.insert("x-token", "cfg-token".parse().unwrap());
7971
7972        let mut opts = http::HeaderMap::new();
7973        opts.insert("x-token", "opt-token".parse().unwrap());
7974
7975        let merged = merge_headers(&cfg, opts);
7976        let vals: Vec<_> = merged.get_all("x-token").iter().collect();
7977        assert_eq!(vals.len(), 1);
7978        assert_eq!(vals[0], "opt-token");
7979    }
7980
7981    #[test]
7982    fn merge_headers_config_only_names_preserved() {
7983        let mut cfg = http::HeaderMap::new();
7984        cfg.insert("x-cfg-only", "kept".parse().unwrap());
7985
7986        let mut opts = http::HeaderMap::new();
7987        opts.insert("x-opt-only", "also-kept".parse().unwrap());
7988
7989        let merged = merge_headers(&cfg, opts);
7990        assert_eq!(merged.get("x-cfg-only").unwrap(), "kept");
7991        assert_eq!(merged.get("x-opt-only").unwrap(), "also-kept");
7992    }
7993
7994    #[test]
7995    fn merge_headers_options_multivalue_replaces_config() {
7996        let mut cfg = http::HeaderMap::new();
7997        cfg.append("x-thing", "cfg-a".parse().unwrap());
7998        cfg.append("x-thing", "cfg-b".parse().unwrap());
7999
8000        let mut opts = http::HeaderMap::new();
8001        opts.append("x-thing", "opt-1".parse().unwrap());
8002        opts.append("x-thing", "opt-2".parse().unwrap());
8003
8004        let merged = merge_headers(&cfg, opts);
8005        let vals: Vec<_> = merged
8006            .get_all("x-thing")
8007            .iter()
8008            .map(|v| v.to_str().unwrap())
8009            .collect();
8010        assert_eq!(vals, vec!["opt-1", "opt-2"]);
8011    }
8012
8013    #[test]
8014    fn merge_headers_empty_config_fast_path() {
8015        let cfg = http::HeaderMap::new();
8016        let mut opts = http::HeaderMap::new();
8017        opts.insert("x", "y".parse().unwrap());
8018
8019        let merged = merge_headers(&cfg, opts);
8020        assert_eq!(merged.get("x").unwrap(), "y");
8021    }
8022
8023    #[test]
8024    fn merge_headers_empty_options_fast_path() {
8025        let mut cfg = http::HeaderMap::new();
8026        cfg.insert("x", "y".parse().unwrap());
8027        let opts = http::HeaderMap::new();
8028
8029        let merged = merge_headers(&cfg, opts);
8030        assert_eq!(merged.get("x").unwrap(), "y");
8031    }
8032
8033    // ========================================================================
8034    // call_unary_get query encoding (Connect GET protocol)
8035    // ========================================================================
8036
8037    /// The order the conformance suite checks for: `connect`, `base64`,
8038    /// `compression`, `encoding`, `message`. Servers accept any order; the
8039    /// recommended order keeps the variable-length `message` last so the
8040    /// prefix is stable for shared caches.
8041    fn assert_connect_get_param_order(query: &str) {
8042        const RANK: &[&str] = &["connect", "base64", "compression", "encoding", "message"];
8043        let mut last = 0;
8044        for pair in query.split('&') {
8045            let key = pair.split_once('=').map_or(pair, |(k, _)| k);
8046            let rank = RANK
8047                .iter()
8048                .position(|k| *k == key)
8049                .unwrap_or_else(|| panic!("unknown query parameter {key:?} in {query:?}"));
8050            assert!(
8051                rank >= last,
8052                "parameter {key:?} out of recommended order in {query:?}",
8053            );
8054            last = rank;
8055        }
8056    }
8057
8058    #[test]
8059    fn get_query_param_order_proto() {
8060        let q = build_connect_get_query(true, None, "proto", "AAAA");
8061        assert_eq!(q, "connect=v1&base64=1&encoding=proto&message=AAAA");
8062        assert_connect_get_param_order(&q);
8063    }
8064
8065    #[test]
8066    fn get_query_param_order_json_uncompressed() {
8067        let q = build_connect_get_query(false, None, "json", "%7B%7D");
8068        assert_eq!(q, "connect=v1&encoding=json&message=%7B%7D");
8069        assert_connect_get_param_order(&q);
8070    }
8071
8072    #[test]
8073    fn get_query_param_order_compressed() {
8074        let q = build_connect_get_query(true, Some("gzip"), "proto", "H4sI");
8075        assert_eq!(
8076            q,
8077            "connect=v1&base64=1&compression=gzip&encoding=proto&message=H4sI",
8078        );
8079        assert_connect_get_param_order(&q);
8080    }
8081
8082    #[test]
8083    fn get_query_param_order_json_compressed() {
8084        // Compressed JSON forces base64 (compressed bytes are binary).
8085        let q = build_connect_get_query(true, Some("gzip"), "json", "H4sI");
8086        assert_eq!(
8087            q,
8088            "connect=v1&base64=1&compression=gzip&encoding=json&message=H4sI",
8089        );
8090        assert_connect_get_param_order(&q);
8091    }
8092
8093    #[test]
8094    fn get_base64_encoding_matches_rfc4648_urlsafe_no_pad() {
8095        // Verify we use the exact encoding the spec requires: RFC 4648 §5
8096        // URL-safe base64, no padding. Matches connect-go's
8097        // base64.RawURLEncoding.EncodeToString.
8098        use base64::Engine;
8099        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"\xfa\xfb\xfc");
8100        // Standard base64 would be +vv8 (with +/). URL-safe: -_ instead.
8101        // 0xfa = 11111010, 0xfb = 11111011, 0xfc = 11111100
8102        // → 111110 101111 101111 1100(00) = 62 47 47 48 in b64 alphabet
8103        // URL-safe: 62='-', 47='v', 48='w' (wait, let me just check the output)
8104        assert!(!encoded.contains('+'), "URL-safe must not contain +");
8105        assert!(!encoded.contains('/'), "URL-safe must not contain /");
8106        assert!(!encoded.contains('='), "no-pad must not contain =");
8107
8108        // Round-trip: our server's decode_get_message must accept this.
8109        let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
8110            .decode(&encoded)
8111            .unwrap();
8112        assert_eq!(decoded, b"\xfa\xfb\xfc");
8113    }
8114
8115    // ========================================================================
8116    // parse_connect_client_stream_envelopes tests
8117    // ========================================================================
8118
8119    /// A second data envelope is rejected before its payload is touched: the
8120    /// second envelope here is flagged compressed but contains garbage, so if
8121    /// the parser tried to decompress it the error would be a decompression
8122    /// failure rather than the multiple-messages error asserted below.
8123    #[cfg(feature = "gzip")]
8124    #[test]
8125    fn client_stream_response_rejects_second_message_before_decompression() {
8126        let registry = crate::compression::CompressionRegistry::default();
8127
8128        let mut body = Envelope::data(Bytes::from_static(b"first"))
8129            .encode()
8130            .to_vec();
8131        body.extend_from_slice(
8132            &Envelope::compressed(Bytes::from_static(b"not gzip data")).encode(),
8133        );
8134        body.extend_from_slice(&Envelope::end_stream(Bytes::from_static(b"{}")).encode());
8135
8136        let err = parse_connect_client_stream_envelopes(
8137            Bytes::from(body),
8138            &registry,
8139            Some("gzip"),
8140            1024 * 1024,
8141            &http::HeaderMap::new(),
8142        )
8143        .unwrap_err();
8144        assert_eq!(err.code, ErrorCode::Unimplemented);
8145        assert!(
8146            err.to_string().contains("multiple data messages"),
8147            "unexpected error: {err}"
8148        );
8149    }
8150
8151    /// A malformed compressed response payload surfaces as `data_loss` on
8152    /// the client: the compression provider classifies malformed input as
8153    /// `invalid_argument` (sender fault), and on the response path the
8154    /// sender is the server, so the code is remapped rather than blaming
8155    /// the caller.
8156    #[cfg(feature = "gzip")]
8157    #[test]
8158    fn malformed_compressed_response_payload_is_data_loss() {
8159        let registry = crate::compression::CompressionRegistry::default();
8160
8161        let mut body = Envelope::compressed(Bytes::from_static(b"not gzip data"))
8162            .encode()
8163            .to_vec();
8164        body.extend_from_slice(&Envelope::end_stream(Bytes::from_static(b"{}")).encode());
8165
8166        let err = parse_connect_client_stream_envelopes(
8167            Bytes::from(body),
8168            &registry,
8169            Some("gzip"),
8170            1024 * 1024,
8171            &http::HeaderMap::new(),
8172        )
8173        .unwrap_err();
8174        assert_eq!(err.code, ErrorCode::DataLoss, "unexpected error: {err}");
8175    }
8176
8177    /// A corrupt END_STREAM payload fails before any trailing metadata can
8178    /// be parsed, so the response headers are the only context the caller
8179    /// gets — they must be there.
8180    #[cfg(feature = "gzip")]
8181    #[test]
8182    fn client_stream_end_stream_decompression_failure_carries_headers() {
8183        use crate::envelope::flags;
8184
8185        let registry = crate::compression::CompressionRegistry::default();
8186
8187        let mut body = Envelope::data(Bytes::from_static(b"only"))
8188            .encode()
8189            .to_vec();
8190        body.extend_from_slice(
8191            &Envelope {
8192                flags: flags::COMPRESSED | flags::END_STREAM,
8193                data: Bytes::from_static(b"not gzip data"),
8194            }
8195            .encode(),
8196        );
8197
8198        let mut resp_headers = http::HeaderMap::new();
8199        resp_headers.insert("x-from-headers", http::HeaderValue::from_static("yes"));
8200
8201        let err = parse_connect_client_stream_envelopes(
8202            Bytes::from(body),
8203            &registry,
8204            Some("gzip"),
8205            1024 * 1024,
8206            &resp_headers,
8207        )
8208        .unwrap_err();
8209        assert_eq!(err.code, ErrorCode::DataLoss, "unexpected error: {err}");
8210        assert_eq!(err.response_headers().get("x-from-headers").unwrap(), "yes");
8211    }
8212
8213    /// The response-path remap touches only the two decompression codes:
8214    /// `invalid_argument` → `data_loss`, `unimplemented` → `internal`;
8215    /// everything else passes through unchanged.
8216    #[test]
8217    fn response_decompression_error_remap() {
8218        let e = map_response_decompression_error(ConnectError::invalid_argument("corrupt"));
8219        assert_eq!(e.code, ErrorCode::DataLoss);
8220        let e = map_response_decompression_error(ConnectError::unimplemented("unknown encoding"));
8221        assert_eq!(e.code, ErrorCode::Internal);
8222        let e = map_response_decompression_error(ConnectError::resource_exhausted("too big"));
8223        assert_eq!(e.code, ErrorCode::ResourceExhausted);
8224    }
8225
8226    /// Scanning stops at the END_STREAM envelope: the single message and the
8227    /// metadata trailers are returned, and trailing bytes after END_STREAM
8228    /// are ignored rather than decoded.
8229    #[test]
8230    fn client_stream_response_stops_at_end_stream() {
8231        let registry = crate::compression::CompressionRegistry::new();
8232
8233        let mut body = Envelope::data(Bytes::from_static(b"only"))
8234            .encode()
8235            .to_vec();
8236        body.extend_from_slice(
8237            &Envelope::end_stream(Bytes::from_static(b"{\"metadata\":{\"x-extra\":[\"1\"]}}"))
8238                .encode(),
8239        );
8240        body.extend_from_slice(&[0xAA_u8; 256]);
8241
8242        let (message, trailers) = parse_connect_client_stream_envelopes(
8243            Bytes::from(body),
8244            &registry,
8245            None,
8246            1024,
8247            &http::HeaderMap::new(),
8248        )
8249        .unwrap();
8250        assert_eq!(&message[..], b"only");
8251        assert_eq!(trailers.get("x-extra").unwrap(), "1");
8252    }
8253
8254    /// An END_STREAM envelope carrying an error surfaces it with the response
8255    /// headers and metadata trailers attached.
8256    #[test]
8257    fn client_stream_response_end_stream_error() {
8258        let registry = crate::compression::CompressionRegistry::new();
8259
8260        let mut body = Envelope::data(Bytes::from_static(b"only"))
8261            .encode()
8262            .to_vec();
8263        body.extend_from_slice(
8264            &Envelope::end_stream(Bytes::from_static(
8265                b"{\"metadata\":{\"x-meta\":[\"m\"]},\"error\":{\"code\":\"resource_exhausted\",\"message\":\"too much\"}}",
8266            ))
8267            .encode(),
8268        );
8269
8270        let mut resp_headers = http::HeaderMap::new();
8271        resp_headers.insert("x-from-headers", http::HeaderValue::from_static("yes"));
8272
8273        let err = parse_connect_client_stream_envelopes(
8274            Bytes::from(body),
8275            &registry,
8276            None,
8277            1024,
8278            &resp_headers,
8279        )
8280        .unwrap_err();
8281        assert_eq!(err.code, ErrorCode::ResourceExhausted);
8282        assert_eq!(err.message.as_deref(), Some("too much"));
8283        assert_eq!(
8284            err.response_headers().get("x-from-headers").unwrap(),
8285            "yes",
8286            "response headers must be attached to the END_STREAM error"
8287        );
8288        assert_eq!(
8289            err.trailers().get("x-meta").unwrap(),
8290            "m",
8291            "END_STREAM metadata must be attached to the error as trailers"
8292        );
8293    }
8294
8295    /// Malformed Connect END_STREAM JSON is a protocol error. It must not be
8296    /// treated as an empty successful end-stream payload.
8297    #[test]
8298    fn client_stream_response_malformed_end_stream_json_errors() {
8299        let registry = crate::compression::CompressionRegistry::new();
8300
8301        let mut body = Envelope::data(Bytes::from_static(b"only"))
8302            .encode()
8303            .to_vec();
8304        body.extend_from_slice(&Envelope::end_stream(Bytes::from_static(b"not json")).encode());
8305
8306        let mut resp_headers = http::HeaderMap::new();
8307        resp_headers.insert("x-from-headers", http::HeaderValue::from_static("yes"));
8308
8309        let err = parse_connect_client_stream_envelopes(
8310            Bytes::from(body),
8311            &registry,
8312            None,
8313            1024,
8314            &resp_headers,
8315        )
8316        .unwrap_err();
8317        assert_eq!(err.code, ErrorCode::Internal);
8318        assert!(
8319            err.to_string()
8320                .contains("malformed Connect END_STREAM JSON"),
8321            "unexpected error: {err}"
8322        );
8323        assert_eq!(err.response_headers().get("x-from-headers").unwrap(), "yes");
8324        assert!(err.trailers().is_empty());
8325    }
8326
8327    /// A body with no data envelope (END_STREAM only) is rejected.
8328    #[test]
8329    fn client_stream_response_requires_a_message() {
8330        let registry = crate::compression::CompressionRegistry::new();
8331        let body = Envelope::end_stream(Bytes::from_static(b"{}")).encode();
8332
8333        let err = parse_connect_client_stream_envelopes(
8334            body,
8335            &registry,
8336            None,
8337            1024,
8338            &http::HeaderMap::new(),
8339        )
8340        .unwrap_err();
8341        assert_eq!(err.code, ErrorCode::Unimplemented);
8342        assert!(
8343            err.to_string().contains("no data messages"),
8344            "unexpected error: {err}"
8345        );
8346    }
8347
8348    /// Compressed data and END_STREAM envelopes decompress through the
8349    /// registry and behave like their uncompressed equivalents.
8350    #[cfg(feature = "gzip")]
8351    #[test]
8352    fn client_stream_response_compressed_envelopes() {
8353        use crate::compression::{CompressionProvider, GzipProvider};
8354
8355        let registry = crate::compression::CompressionRegistry::default();
8356        let gzip = GzipProvider::default();
8357
8358        let mut body = Envelope::compressed(gzip.compress(b"only").unwrap())
8359            .encode()
8360            .to_vec();
8361        let mut end_stream = Envelope::compressed(
8362            gzip.compress(b"{\"metadata\":{\"x-extra\":[\"1\"]}}")
8363                .unwrap(),
8364        )
8365        .encode()
8366        .to_vec();
8367        end_stream[0] |= 0x02; // also set the END_STREAM flag
8368        body.extend_from_slice(&end_stream);
8369
8370        let (message, trailers) = parse_connect_client_stream_envelopes(
8371            Bytes::from(body),
8372            &registry,
8373            Some("gzip"),
8374            1024 * 1024,
8375            &http::HeaderMap::new(),
8376        )
8377        .unwrap();
8378        assert_eq!(&message[..], b"only");
8379        assert_eq!(trailers.get("x-extra").unwrap(), "1");
8380    }
8381
8382    /// A data envelope that only appears after the END_STREAM envelope does
8383    /// not count as the response message: the scan stops at END_STREAM, so
8384    /// the response is rejected for having no data message.
8385    #[test]
8386    fn client_stream_response_data_after_end_stream_is_not_a_message() {
8387        let registry = crate::compression::CompressionRegistry::new();
8388
8389        let mut body = Envelope::end_stream(Bytes::from_static(b"{}"))
8390            .encode()
8391            .to_vec();
8392        body.extend_from_slice(&Envelope::data(Bytes::from_static(b"late")).encode());
8393
8394        let err = parse_connect_client_stream_envelopes(
8395            Bytes::from(body),
8396            &registry,
8397            None,
8398            1024,
8399            &http::HeaderMap::new(),
8400        )
8401        .unwrap_err();
8402        assert_eq!(err.code, ErrorCode::Unimplemented);
8403        assert!(
8404            err.to_string().contains("no data messages"),
8405            "unexpected error: {err}"
8406        );
8407    }
8408
8409    /// A data envelope followed by EOF, with no END_STREAM envelope, is a
8410    /// truncated response rather than a completed one: it is rejected with
8411    /// `internal` instead of succeeding with empty trailers, matching the
8412    /// `ServerStream` Connect EOF behavior.
8413    #[test]
8414    fn client_stream_response_requires_end_stream_after_message() {
8415        let registry = crate::compression::CompressionRegistry::new();
8416
8417        let body = Envelope::data(Bytes::from_static(b"only")).encode();
8418
8419        let err = parse_connect_client_stream_envelopes(
8420            body,
8421            &registry,
8422            None,
8423            1024,
8424            &http::HeaderMap::new(),
8425        )
8426        .unwrap_err();
8427        assert_eq!(err.code, ErrorCode::Internal);
8428        assert_eq!(
8429            err.message.as_deref(),
8430            Some("Connect streaming response ended without END_STREAM envelope"),
8431        );
8432    }
8433
8434    /// A data envelope followed by a truncated END_STREAM envelope (its
8435    /// declared payload never arrives) is also a truncated response: the
8436    /// partial envelope decodes to "needs more data", so END_STREAM is never
8437    /// observed and the response is rejected with `internal`.
8438    #[test]
8439    fn client_stream_response_requires_complete_end_stream_after_message() {
8440        let registry = crate::compression::CompressionRegistry::new();
8441
8442        let mut body = Envelope::data(Bytes::from_static(b"only"))
8443            .encode()
8444            .to_vec();
8445        let end_stream = Envelope::end_stream(Bytes::from_static(b"{}")).encode();
8446        // Append everything but the final byte of the END_STREAM envelope.
8447        body.extend_from_slice(&end_stream[..end_stream.len() - 1]);
8448
8449        let err = parse_connect_client_stream_envelopes(
8450            Bytes::from(body),
8451            &registry,
8452            None,
8453            1024,
8454            &http::HeaderMap::new(),
8455        )
8456        .unwrap_err();
8457        assert_eq!(err.code, ErrorCode::Internal);
8458        assert_eq!(
8459            err.message.as_deref(),
8460            Some("Connect streaming response ended without END_STREAM envelope"),
8461        );
8462    }
8463
8464    /// A data envelope followed by an empty END_STREAM envelope (`{}`) is a
8465    /// complete response: the message is returned with empty trailers.
8466    #[test]
8467    fn client_stream_response_end_stream_completes_the_response() {
8468        let registry = crate::compression::CompressionRegistry::new();
8469
8470        let mut body = Envelope::data(Bytes::from_static(b"only"))
8471            .encode()
8472            .to_vec();
8473        body.extend_from_slice(&Envelope::end_stream(Bytes::from_static(b"{}")).encode());
8474
8475        let (message, trailers) = parse_connect_client_stream_envelopes(
8476            Bytes::from(body),
8477            &registry,
8478            None,
8479            1024,
8480            &http::HeaderMap::new(),
8481        )
8482        .unwrap();
8483        assert_eq!(&message[..], b"only");
8484        assert!(trailers.is_empty());
8485    }
8486}