Skip to main content

kube_client/client/
mod.rs

1//! API client for interacting with the Kubernetes API
2//!
3//! The [`Client`] uses standard kube error handling.
4//!
5//! This client can be used on its own or in conjunction with the [`Api`][crate::api::Api]
6//! type for more structured interaction with the kubernetes API.
7//!
8//! The [`Client`] can also be used with [`Discovery`](crate::Discovery) to dynamically
9//! retrieve the resources served by the kubernetes API.
10use either::{Either, Left, Right};
11use futures::{AsyncBufRead, StreamExt, TryStream, TryStreamExt, future::BoxFuture};
12use http::{self, Request, Response};
13use http_body_util::BodyExt;
14#[cfg(feature = "ws")]
15use hyper_util::rt::TokioIo;
16use jiff::Timestamp;
17use k8s_openapi::apimachinery::pkg::apis::meta::v1 as k8s_meta_v1;
18use kube_core::{discovery::v2::ACCEPT_AGGREGATED_DISCOVERY_V2, response::Status};
19use serde::de::DeserializeOwned;
20use serde_json::{self, Value};
21#[cfg(feature = "ws")]
22use tokio_tungstenite::{WebSocketStream, tungstenite as ws};
23use tokio_util::{
24    codec::{FramedRead, LinesCodec, LinesCodecError},
25    io::StreamReader,
26};
27use tower::{BoxError, Service, ServiceExt as _, buffer::Buffer};
28use tower_http::ServiceExt as _;
29
30pub use self::body::Body;
31use crate::{Config, Error, Result, api::WatchEvent, config::Kubeconfig};
32
33mod auth;
34mod body;
35mod builder;
36pub use kube_core::discovery::v2::{
37    APIGroupDiscovery, APIGroupDiscoveryList, APIResourceDiscovery, APISubresourceDiscovery,
38    APIVersionDiscovery, GroupVersionKind as DiscoveryGroupVersionKind,
39};
40#[cfg_attr(docsrs, doc(cfg(feature = "unstable-client")))]
41#[cfg(feature = "unstable-client")]
42mod client_ext;
43#[cfg_attr(docsrs, doc(cfg(feature = "unstable-client")))]
44#[cfg(feature = "unstable-client")]
45pub use client_ext::scope;
46mod config_ext;
47pub use auth::Error as AuthError;
48pub use config_ext::ConfigExt;
49pub mod middleware;
50pub mod retry;
51
52#[cfg(any(feature = "rustls-tls", feature = "openssl-tls"))]
53mod tls;
54
55#[cfg(feature = "openssl-tls")]
56pub use tls::openssl_tls::Error as OpensslTlsError;
57#[cfg(feature = "rustls-tls")]
58pub use tls::rustls_tls::Error as RustlsTlsError;
59#[cfg(feature = "ws")]
60mod upgrade;
61
62#[cfg(feature = "oauth")]
63#[cfg_attr(docsrs, doc(cfg(feature = "oauth")))]
64pub use auth::OAuthError;
65
66#[cfg(feature = "oidc")]
67#[cfg_attr(docsrs, doc(cfg(feature = "oidc")))]
68pub use auth::oidc_errors;
69
70#[cfg(feature = "ws")]
71pub use upgrade::UpgradeConnectionError;
72
73#[cfg(feature = "kubelet-debug")]
74#[cfg_attr(docsrs, doc(cfg(feature = "kubelet-debug")))]
75mod kubelet_debug;
76
77pub use builder::{ClientBuilder, DynBody};
78
79/// Client for connecting with a Kubernetes cluster.
80///
81/// The easiest way to instantiate the client is either by
82/// inferring the configuration from the environment using
83/// [`Client::try_default`] or with an existing [`Config`]
84/// using [`Client::try_from`].
85#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
86#[derive(Clone)]
87pub struct Client {
88    // - `Buffer` for cheap clone
89    // - `BoxFuture` for dynamic response future type
90    inner: Buffer<Request<Body>, BoxFuture<'static, Result<Response<Body>, BoxError>>>,
91    default_ns: String,
92    valid_until: Option<Timestamp>,
93}
94
95/// Represents a WebSocket connection.
96/// Value returned by [`Client::connect`].
97#[cfg(feature = "ws")]
98#[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
99pub struct Connection {
100    stream: WebSocketStream<TokioIo<hyper::upgrade::Upgraded>>,
101    protocol: upgrade::StreamProtocol,
102}
103
104#[cfg(feature = "ws")]
105#[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
106impl Connection {
107    /// Return true if the stream supports graceful close signaling.
108    pub fn supports_stream_close(&self) -> bool {
109        self.protocol.supports_stream_close()
110    }
111
112    /// Transform into the raw WebSocketStream.
113    pub fn into_stream(self) -> WebSocketStream<TokioIo<hyper::upgrade::Upgraded>> {
114        self.stream
115    }
116}
117
118/// Constructors and low-level api interfaces.
119///
120/// Most users only need [`Client::try_default`] or [`Client::new`] from this block.
121///
122/// The many various lower level interfaces here are for more advanced use-cases with specific requirements.
123impl Client {
124    /// Create a [`Client`] using a custom `Service` stack.
125    ///
126    /// [`ConfigExt`](crate::client::ConfigExt) provides extensions for
127    /// building a custom stack.
128    ///
129    /// To create with the default stack with a [`Config`], use
130    /// [`Client::try_from`].
131    ///
132    /// To create with the default stack with an inferred [`Config`], use
133    /// [`Client::try_default`].
134    ///
135    /// # Example
136    ///
137    /// ```rust
138    /// # async fn doc() -> Result<(), Box<dyn std::error::Error>> {
139    /// use kube::{client::ConfigExt, Client, Config};
140    /// use tower::{BoxError, ServiceBuilder};
141    /// use hyper_util::rt::TokioExecutor;
142    ///
143    /// let config = Config::infer().await?;
144    /// let service = ServiceBuilder::new()
145    ///     .layer(config.base_uri_layer())
146    ///     .option_layer(config.auth_layer()?)
147    ///     .map_err(BoxError::from)
148    ///     .service(hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build_http());
149    /// let client = Client::new(service, config.default_namespace);
150    /// # Ok(())
151    /// # }
152    /// ```
153    pub fn new<S, B, T>(service: S, default_namespace: T) -> Self
154    where
155        S: Service<Request<Body>, Response = Response<B>> + Send + 'static,
156        S::Future: Send + 'static,
157        S::Error: Into<BoxError>,
158        B: http_body::Body<Data = bytes::Bytes> + Send + 'static,
159        B::Error: Into<BoxError>,
160        T: Into<String>,
161    {
162        // Transform response body to `crate::client::Body` and use type erased error to avoid type parameters.
163        let service = service
164            .map_response_body(Body::wrap_body)
165            .map_err(Into::into)
166            .boxed();
167        Self {
168            inner: Buffer::new(service, 1024),
169            default_ns: default_namespace.into(),
170            valid_until: None,
171        }
172    }
173
174    /// Sets an expiration timestamp to the client, which has to be checked by the user using [`Client::valid_until`] function.
175    pub fn with_valid_until(self, valid_until: Option<Timestamp>) -> Self {
176        Client { valid_until, ..self }
177    }
178
179    /// Get the expiration timestamp of the client, if it has been set.
180    pub fn valid_until(&self) -> &Option<Timestamp> {
181        &self.valid_until
182    }
183
184    /// Create and initialize a [`Client`] using the inferred configuration.
185    ///
186    /// Will use [`Config::infer`] which attempts to load the local kubeconfig first,
187    /// and then if that fails, trying the in-cluster environment variables.
188    ///
189    /// Will fail if neither configuration could be loaded.
190    ///
191    /// ```rust
192    /// # async fn doc() -> Result<(), Box<dyn std::error::Error>> {
193    /// # use kube::Client;
194    /// let client = Client::try_default().await?;
195    /// # Ok(())
196    /// # }
197    /// ```
198    ///
199    /// If you already have a [`Config`] then use [`Client::try_from`](Self::try_from)
200    /// instead.
201    pub async fn try_default() -> Result<Self> {
202        Self::try_from(Config::infer().await.map_err(Error::InferConfig)?)
203    }
204
205    /// Get the default namespace for the client
206    ///
207    /// The namespace is either configured on `context` in the kubeconfig,
208    /// falls back to `default` when running locally,
209    /// or uses the service account's namespace when deployed in-cluster.
210    pub fn default_namespace(&self) -> &str {
211        &self.default_ns
212    }
213
214    /// Perform a raw HTTP request against the API and return the raw response back.
215    /// This method can be used to get raw access to the API which may be used to, for example,
216    /// create a proxy server or application-level gateway between localhost and the API server.
217    pub async fn send(&self, request: Request<Body>) -> Result<Response<Body>> {
218        let mut svc = self.inner.clone();
219        let res = svc
220            .ready()
221            .await
222            .map_err(Error::Service)?
223            .call(request)
224            .await
225            .map_err(|err| {
226                // Error decorating request
227                err.downcast::<Error>()
228                    .map(|e| *e)
229                    // Error requesting
230                    .or_else(|err| err.downcast::<hyper::Error>().map(|err| Error::HyperError(*err)))
231                    // Error from another middleware
232                    .unwrap_or_else(Error::Service)
233            })?;
234        Ok(res)
235    }
236
237    /// Make WebSocket connection.
238    #[cfg(feature = "ws")]
239    #[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
240    pub async fn connect(&self, request: Request<Vec<u8>>) -> Result<Connection> {
241        use http::header::HeaderValue;
242        let (mut parts, body) = request.into_parts();
243        parts
244            .headers
245            .insert(http::header::CONNECTION, HeaderValue::from_static("Upgrade"));
246        parts
247            .headers
248            .insert(http::header::UPGRADE, HeaderValue::from_static("websocket"));
249        parts.headers.insert(
250            http::header::SEC_WEBSOCKET_VERSION,
251            HeaderValue::from_static("13"),
252        );
253        let key = tokio_tungstenite::tungstenite::handshake::client::generate_key();
254        parts.headers.insert(
255            http::header::SEC_WEBSOCKET_KEY,
256            key.parse().expect("valid header value"),
257        );
258        upgrade::StreamProtocol::add_to_headers(&mut parts.headers)?;
259
260        let res = self.send(Request::from_parts(parts, Body::from(body))).await?;
261        // 4xx/5xx bodies are `metav1.Status`. Surface them as `Error::Api`
262        // before the upgrade check, which only understands 101.
263        let res = handle_api_errors(res).await?;
264        let protocol = upgrade::verify_response(&res, &key).map_err(Error::UpgradeConnection)?;
265        match hyper::upgrade::on(res).await {
266            Ok(upgraded) => Ok(Connection {
267                stream: WebSocketStream::from_raw_socket(
268                    TokioIo::new(upgraded),
269                    ws::protocol::Role::Client,
270                    None,
271                )
272                .await,
273                protocol,
274            }),
275
276            Err(e) => Err(Error::UpgradeConnection(
277                UpgradeConnectionError::GetPendingUpgrade(e),
278            )),
279        }
280    }
281
282    /// Perform a raw HTTP request against the API and deserialize the response
283    /// as JSON to some known type.
284    pub async fn request<T>(&self, request: Request<Vec<u8>>) -> Result<T>
285    where
286        T: DeserializeOwned,
287    {
288        let text = self.request_text(request).await?;
289
290        serde_json::from_str(&text).map_err(|e| {
291            tracing::warn!("{}, {:?}", text, e);
292            Error::SerdeError(e)
293        })
294    }
295
296    /// Perform a raw HTTP request against the API and get back the response
297    /// as a string
298    pub async fn request_text(&self, request: Request<Vec<u8>>) -> Result<String> {
299        let res = self.send(request.map(Body::from)).await?;
300        let res = handle_api_errors(res).await?;
301        let body_bytes = res.into_body().collect().await?.to_bytes();
302        let text = String::from_utf8(body_bytes.to_vec()).map_err(Error::FromUtf8)?;
303        Ok(text)
304    }
305
306    /// Perform a raw HTTP request against the API and stream the response body.
307    ///
308    /// The response can be processed using [`AsyncReadExt`](futures::AsyncReadExt)
309    /// and [`AsyncBufReadExt`](futures::AsyncBufReadExt).
310    pub async fn request_stream(&self, request: Request<Vec<u8>>) -> Result<impl AsyncBufRead + use<>> {
311        let res = self.send(request.map(Body::from)).await?;
312        let res = handle_api_errors(res).await?;
313        // Map the error, since we want to convert this into an `AsyncBufReader` using
314        // `into_async_read` which specifies `std::io::Error` as the stream's error type.
315        let body = res.into_body().into_data_stream().map_err(std::io::Error::other);
316        Ok(body.into_async_read())
317    }
318
319    /// Perform a raw HTTP request against the API and get back either an object
320    /// deserialized as JSON or a [`Status`] Object.
321    pub async fn request_status<T>(&self, request: Request<Vec<u8>>) -> Result<Either<T, Status>>
322    where
323        T: DeserializeOwned,
324    {
325        let text = self.request_text(request).await?;
326        // It needs to be JSON:
327        let v: Value = serde_json::from_str(&text).map_err(Error::SerdeError)?;
328        if v["kind"] == "Status" {
329            tracing::trace!("Status from {}", text);
330            Ok(Right(serde_json::from_str::<Status>(&text).map_err(|e| {
331                tracing::warn!("{}, {:?}", text, e);
332                Error::SerdeError(e)
333            })?))
334        } else {
335            Ok(Left(serde_json::from_str::<T>(&text).map_err(|e| {
336                tracing::warn!("{}, {:?}", text, e);
337                Error::SerdeError(e)
338            })?))
339        }
340    }
341
342    /// Perform a raw request and get back a stream of [`WatchEvent`] objects
343    pub async fn request_events<T>(
344        &self,
345        mut request: Request<Vec<u8>>,
346    ) -> Result<impl TryStream<Item = Result<WatchEvent<T>>> + use<T>>
347    where
348        T: Clone + DeserializeOwned,
349    {
350        // Kubernetes >=1.37 gzips watch streams one member per event, but our decompression
351        // middleware only decodes the first member, so opt watch streams out of compression.
352        // Drop this once tower-http decodes multi-member gzip again: tower-rs/tower-http#737,
353        // fixed upstream by Nullus157/async-compression#489.
354        request
355            .headers_mut()
356            .entry(http::header::ACCEPT_ENCODING)
357            .or_insert(http::HeaderValue::from_static("identity"));
358        let res = self.send(request.map(Body::from)).await?;
359        let res = handle_api_errors(res).await?;
360        // trace!("Streaming from {} -> {}", res.url(), res.status().as_str());
361        tracing::trace!("headers: {:?}", res.headers());
362
363        let frames = FramedRead::new(
364            StreamReader::new(res.into_body().into_data_stream().map_err(|e| {
365                // Resumable watch termination. TLS stacks nest an `io::Error`
366                // of kind `UnexpectedEof`. Chunk decoders still report
367                // "unexpected EOF during chunk" in the error text. Classify
368                // either case by keeping the original error as the source.
369                let chunk_eof = e.to_string().contains("unexpected EOF during chunk");
370                if error_chain_contains_unexpected_eof(&e) || chunk_eof {
371                    return std::io::Error::new(std::io::ErrorKind::UnexpectedEof, e);
372                }
373                std::io::Error::other(e)
374            })),
375            LinesCodec::new(),
376        );
377
378        Ok(frames.filter_map(|res| async {
379            match res {
380                Ok(line) => match serde_json::from_str::<WatchEvent<T>>(&line) {
381                    Ok(event) => Some(Ok(event)),
382                    Err(e) => {
383                        // Ignore EOF error that can happen for incomplete line from `decode_eof`.
384                        if e.is_eof() {
385                            return None;
386                        }
387
388                        // Got general error response
389                        if let Ok(status) = serde_json::from_str::<Status>(&line) {
390                            return Some(Err(Error::Api(status.boxed())));
391                        }
392                        // Parsing error
393                        Some(Err(Error::SerdeError(e)))
394                    }
395                },
396
397                Err(LinesCodecError::Io(e)) => match e.kind() {
398                    // Client timeout
399                    std::io::ErrorKind::TimedOut => {
400                        tracing::warn!("timeout in poll: {}", e); // our client timeout
401                        None
402                    }
403                    // Unexpected EOF from the chunked decoder, or a nested
404                    // `io::ErrorKind::UnexpectedEof`. Tends to happen after 300+s
405                    // of watching. End the stream so it can be resumed.
406                    std::io::ErrorKind::UnexpectedEof => {
407                        tracing::warn!("eof in poll: {:?}", e);
408                        None
409                    }
410                    _ => Some(Err(Error::ReadEvents(e))),
411                },
412
413                // Reached the maximum line length without finding a newline.
414                // This should never happen because we're using the default `usize::MAX`.
415                Err(LinesCodecError::MaxLineLengthExceeded) => {
416                    Some(Err(Error::LinesCodecMaxLineLengthExceeded))
417                }
418            }
419        }))
420    }
421}
422
423/// Low level discovery methods using `k8s_openapi` types.
424///
425/// Consider using the [`discovery`](crate::discovery) module for
426/// easier-to-use variants of this functionality.
427/// The following methods might be deprecated to avoid confusion between similarly named types within `discovery`.
428impl Client {
429    /// Returns apiserver version.
430    pub async fn apiserver_version(&self) -> Result<k8s_openapi::apimachinery::pkg::version::Info> {
431        self.request(Request::get("/version").body(vec![]).map_err(Error::HttpError)?)
432            .await
433    }
434
435    /// Lists api groups that apiserver serves.
436    pub async fn list_api_groups(&self) -> Result<k8s_meta_v1::APIGroupList> {
437        self.request(Request::get("/apis").body(vec![]).map_err(Error::HttpError)?)
438            .await
439    }
440
441    /// Lists resources served in given API group.
442    ///
443    /// ### Example usage:
444    /// ```rust
445    /// # async fn scope(client: kube::Client) -> Result<(), Box<dyn std::error::Error>> {
446    /// let apigroups = client.list_api_groups().await?;
447    /// for g in apigroups.groups {
448    ///     let ver = g
449    ///         .preferred_version
450    ///         .as_ref()
451    ///         .or_else(|| g.versions.first())
452    ///         .expect("preferred or versions exists");
453    ///     let apis = client.list_api_group_resources(&ver.group_version).await?;
454    ///     dbg!(apis);
455    /// }
456    /// # Ok(())
457    /// # }
458    /// ```
459    pub async fn list_api_group_resources(&self, apiversion: &str) -> Result<k8s_meta_v1::APIResourceList> {
460        let url = format!("/apis/{apiversion}");
461        self.request(Request::get(url).body(vec![]).map_err(Error::HttpError)?)
462            .await
463    }
464
465    /// Lists versions of `core` a.k.a. `""` legacy API group.
466    pub async fn list_core_api_versions(&self) -> Result<k8s_meta_v1::APIVersions> {
467        self.request(Request::get("/api").body(vec![]).map_err(Error::HttpError)?)
468            .await
469    }
470
471    /// Lists resources served in particular `core` group version.
472    pub async fn list_core_api_resources(&self, version: &str) -> Result<k8s_meta_v1::APIResourceList> {
473        let url = format!("/api/{version}");
474        self.request(Request::get(url).body(vec![]).map_err(Error::HttpError)?)
475            .await
476    }
477}
478
479/// Aggregated Discovery API methods
480///
481/// These methods use the Aggregated Discovery API (available since Kubernetes 1.26, stable in 1.30)
482/// to fetch all API resources in a single request, reducing the number of API calls compared to
483/// the traditional discovery methods.
484impl Client {
485    /// Returns aggregated discovery for all API groups served at /apis.
486    ///
487    /// This uses the Aggregated Discovery API to fetch all non-core API groups
488    /// and their resources in a single request.
489    ///
490    /// Requires Kubernetes 1.26+ (beta) or 1.30+ (stable).
491    ///
492    /// ### Example usage:
493    /// ```rust,no_run
494    /// # async fn scope(client: kube::Client) -> Result<(), Box<dyn std::error::Error>> {
495    /// let discovery = client.list_api_groups_aggregated().await?;
496    /// for group in discovery.items {
497    ///     let name = group.metadata.as_ref().and_then(|m| m.name.as_ref());
498    ///     println!("Group: {:?}", name);
499    ///     for version in group.versions {
500    ///         println!("  Version: {:?}", version.version);
501    ///         for resource in version.resources {
502    ///             println!("    Resource: {:?}", resource.resource);
503    ///         }
504    ///     }
505    /// }
506    /// # Ok(())
507    /// # }
508    /// ```
509    pub async fn list_api_groups_aggregated(&self) -> Result<APIGroupDiscoveryList> {
510        self.request(
511            Request::get("/apis")
512                .header(http::header::ACCEPT, ACCEPT_AGGREGATED_DISCOVERY_V2)
513                .body(vec![])
514                .map_err(Error::HttpError)?,
515        )
516        .await
517    }
518
519    /// Returns aggregated discovery for core API group served at /api.
520    ///
521    /// This uses the Aggregated Discovery API to fetch the core API group
522    /// and all its resources in a single request.
523    ///
524    /// Requires Kubernetes 1.26+ (beta) or 1.30+ (stable).
525    ///
526    /// ### Example usage:
527    /// ```rust,no_run
528    /// # async fn scope(client: kube::Client) -> Result<(), Box<dyn std::error::Error>> {
529    /// let discovery = client.list_core_api_versions_aggregated().await?;
530    /// for group in discovery.items {
531    ///     for version in group.versions {
532    ///         println!("Core version: {:?}", version.version);
533    ///         for resource in version.resources {
534    ///             println!("  Resource: {:?} (scope: {:?})", resource.resource, resource.scope);
535    ///         }
536    ///     }
537    /// }
538    /// # Ok(())
539    /// # }
540    /// ```
541    pub async fn list_core_api_versions_aggregated(&self) -> Result<APIGroupDiscoveryList> {
542        self.request(
543            Request::get("/api")
544                .header(http::header::ACCEPT, ACCEPT_AGGREGATED_DISCOVERY_V2)
545                .body(vec![])
546                .map_err(Error::HttpError)?,
547        )
548        .await
549    }
550}
551
552// `err` or an error reached through `std::error::Error::source` is an
553// `std::io::Error` of kind `UnexpectedEof`.
554fn error_chain_contains_unexpected_eof(err: &(dyn std::error::Error + 'static)) -> bool {
555    // HTTP and TLS stacks nest a handful of errors. Stop if a custom `source`
556    // implementation cycles so a watch poll cannot stall.
557    const MAX_SOURCE_DEPTH: usize = 16;
558    let mut current = Some(err);
559    for _ in 0..MAX_SOURCE_DEPTH {
560        let Some(err) = current else {
561            return false;
562        };
563        if let Some(io_err) = err.downcast_ref::<std::io::Error>()
564            && io_err.kind() == std::io::ErrorKind::UnexpectedEof
565        {
566            return true;
567        }
568        current = err.source();
569    }
570    false
571}
572
573/// Kubernetes returned error handling
574///
575/// Either kube returned an explicit ApiError struct,
576/// or it somehow returned something we couldn't parse as one.
577///
578/// In either case, present an ApiError upstream.
579async fn handle_api_errors(res: Response<Body>) -> Result<Response<Body>> {
580    let status = res.status();
581    if status.is_client_error() || status.is_server_error() {
582        // trace!("Status = {:?} for {}", status, res.url());
583        let body_bytes = res.into_body().collect().await?.to_bytes();
584        let text = String::from_utf8(body_bytes.to_vec()).map_err(Error::FromUtf8)?;
585        // Print better debug when things do fail
586        // trace!("Parsing error: {}", text);
587        if let Ok(status) = serde_json::from_str::<Status>(&text) {
588            tracing::debug!("Unsuccessful: {status:?}");
589            Err(Error::Api(status.boxed()))
590        } else {
591            // Not every error response is a JSON `Status`. A proxy, ingress
592            // controller, or a bare API path that doesn't exist (e.g. a CRD
593            // that isn't installed yet) can return a plain-text or HTML body
594            // instead. This is routine rather than exceptional, so it's
595            // logged at `debug` like the parsed case above rather than `warn`.
596            let text = text.trim();
597            tracing::debug!("Unsuccessful data error parse: {text}");
598            let status = Status::failure(text, "Failed to parse error data").with_code(status.as_u16());
599            tracing::debug!("Unsuccessful: {status:?} (reconstruct)");
600            Err(Error::Api(status.boxed()))
601        }
602    } else {
603        Ok(res)
604    }
605}
606
607impl TryFrom<Config> for Client {
608    type Error = Error;
609
610    /// Builds a default [`Client`] from a [`Config`].
611    ///
612    /// See [`ClientBuilder`] or [`Client::new`] if more customization is required
613    fn try_from(config: Config) -> Result<Self> {
614        Ok(ClientBuilder::try_from(config)?.build())
615    }
616}
617
618impl TryFrom<Kubeconfig> for Client {
619    type Error = Error;
620
621    fn try_from(kubeconfig: Kubeconfig) -> Result<Self> {
622        let config = Config::try_from(kubeconfig)?;
623        Ok(ClientBuilder::try_from(config)?.build())
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use std::{fmt, io::ErrorKind, pin::pin, time::Duration};
630
631    use crate::{
632        Api, Client, Error, Result,
633        api::WatchEvent,
634        client::Body,
635        config::{AuthInfo, Cluster, Context, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext},
636    };
637
638    use bytes::Bytes;
639    use futures::{Stream, StreamExt};
640    use http::{Request, Response};
641    use http_body::Frame;
642    use http_body_util::StreamBody;
643    use k8s_openapi::api::core::v1::Pod;
644    use kube_core::metadata::PartialObjectMeta;
645    use tower_test::mock;
646
647    const WATCH_POLL_TIMEOUT: Duration = Duration::from_secs(2);
648
649    #[tokio::test]
650    async fn test_default_ns() {
651        let (mock_service, _) = mock::pair::<Request<Body>, Response<Body>>();
652        let client = Client::new(mock_service, "test-namespace");
653        assert_eq!(client.default_namespace(), "test-namespace");
654    }
655
656    #[tokio::test]
657    async fn test_try_from_kubeconfig() {
658        let config = Kubeconfig {
659            current_context: Some("test-context".to_string()),
660            auth_infos: vec![NamedAuthInfo {
661                name: "test-user".to_string(),
662                auth_info: Some(AuthInfo::default()), // <-- empty but valid
663                ..Default::default()
664            }],
665            contexts: vec![NamedContext {
666                name: "test-context".to_string(),
667                context: Some(Context {
668                    cluster: "test-cluster".to_string(),
669                    user: Some("test-user".to_string()),
670                    namespace: Some("test-namespace".to_string()),
671                    ..Default::default()
672                }),
673                ..Default::default()
674            }],
675            clusters: vec![NamedCluster {
676                name: "test-cluster".to_string(),
677                cluster: Some(Cluster {
678                    server: Some("http://localhost:8080".to_string()),
679                    ..Default::default()
680                }),
681                ..Default::default()
682            }],
683            ..Default::default()
684        };
685        let client = Client::try_from(config).expect("Failed to create client from kubeconfig");
686        assert_eq!(client.default_namespace(), "test-namespace");
687    }
688
689    #[tokio::test]
690    async fn test_mock() {
691        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
692        let spawned = tokio::spawn(async move {
693            // Receive a request for pod and respond with some data
694            let mut handle = pin!(handle);
695            let (request, send) = handle.next_request().await.expect("service not called");
696            assert_eq!(request.method(), http::Method::GET);
697            assert_eq!(request.uri().to_string(), "/api/v1/namespaces/default/pods/test");
698            let pod: Pod = serde_json::from_value(serde_json::json!({
699                "apiVersion": "v1",
700                "kind": "Pod",
701                "metadata": {
702                    "name": "test",
703                    "annotations": { "kube-rs": "test" },
704                },
705                "spec": {
706                    "containers": [{ "name": "test", "image": "test-image" }],
707                }
708            }))
709            .unwrap();
710            send.send_response(Response::new(Body::from(serde_json::to_vec(&pod).unwrap())));
711        });
712
713        let pods: Api<Pod> = Api::default_namespaced(Client::new(mock_service, "default"));
714        let pod = pods.get("test").await.unwrap();
715        assert_eq!(pod.metadata.annotations.unwrap().get("kube-rs").unwrap(), "test");
716        spawned.await.unwrap();
717    }
718
719    #[tokio::test]
720    async fn test_partial_object_meta_get_uses_metadata_header() {
721        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
722        let spawned = tokio::spawn(async move {
723            let mut handle = pin!(handle);
724            let (request, send) = handle.next_request().await.expect("service not called");
725            // Verify the metadata-only Accept header is set
726            assert_eq!(
727                request.headers().get(http::header::ACCEPT).unwrap(),
728                "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1"
729            );
730            let pod_meta: PartialObjectMeta<Pod> =
731                serde_json::from_value(serde_json::json!({
732                    "apiVersion": "meta.k8s.io/v1",
733                    "kind": "PartialObjectMetadata",
734                    "metadata": {
735                        "name": "test",
736                    },
737                }))
738                .unwrap();
739            send.send_response(Response::new(Body::from(
740                serde_json::to_vec(&pod_meta).unwrap(),
741            )));
742        });
743
744        let pods: Api<PartialObjectMeta<Pod>> =
745            Api::default_namespaced(Client::new(mock_service, "default"));
746        let pod = pods.get("test").await.unwrap();
747        assert_eq!(pod.metadata.name, Some("test".to_string()));
748        spawned.await.unwrap();
749    }
750
751    #[tokio::test]
752    async fn test_partial_object_meta_list_uses_metadata_header() {
753        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
754        let spawned = tokio::spawn(async move {
755            let mut handle = pin!(handle);
756            let (request, send) = handle.next_request().await.expect("service not called");
757            assert_eq!(
758                request.headers().get(http::header::ACCEPT).unwrap(),
759                "application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1"
760            );
761            send.send_response(Response::new(Body::from(
762                serde_json::to_vec(&serde_json::json!({
763                    "apiVersion": "meta.k8s.io/v1",
764                    "kind": "PartialObjectMetadataList",
765                    "metadata": { "resourceVersion": "1" },
766                    "items": [],
767                }))
768                .unwrap(),
769            )));
770        });
771
772        let pods: Api<PartialObjectMeta<Pod>> =
773            Api::default_namespaced(Client::new(mock_service, "default"));
774        let list = pods.list(&Default::default()).await.unwrap();
775        assert_eq!(list.items.len(), 0);
776        spawned.await.unwrap();
777    }
778
779    #[tokio::test]
780    async fn test_partial_object_meta_patch_uses_metadata_header() {
781        use kube_core::params::{Patch, PatchParams};
782
783        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
784        let spawned = tokio::spawn(async move {
785            let mut handle = pin!(handle);
786            let (request, send) = handle.next_request().await.expect("service not called");
787            assert_eq!(request.method(), http::Method::PATCH);
788            assert_eq!(
789                request.headers().get(http::header::ACCEPT).unwrap(),
790                "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1"
791            );
792            send.send_response(Response::new(Body::from(
793                serde_json::to_vec(&serde_json::json!({
794                    "apiVersion": "meta.k8s.io/v1",
795                    "kind": "PartialObjectMetadata",
796                    "metadata": { "name": "test" },
797                }))
798                .unwrap(),
799            )));
800        });
801
802        let pods: Api<PartialObjectMeta<Pod>> =
803            Api::default_namespaced(Client::new(mock_service, "default"));
804        let patch = Patch::Merge(serde_json::json!({ "metadata": { "labels": { "a": "b" } } }));
805        let pod = pods.patch("test", &PatchParams::default(), &patch).await.unwrap();
806        assert_eq!(pod.metadata.name, Some("test".to_string()));
807        spawned.await.unwrap();
808    }
809
810    #[tokio::test]
811    async fn test_partial_object_meta_watch_uses_metadata_header() {
812        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
813        let spawned = tokio::spawn(async move {
814            let mut handle = pin!(handle);
815            let (request, send) = handle.next_request().await.expect("service not called");
816            assert_eq!(
817                request.headers().get(http::header::ACCEPT).unwrap(),
818                "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1"
819            );
820            // Send an empty response to close the stream
821            send.send_response(Response::new(Body::from(vec![])));
822        });
823
824        let pods: Api<PartialObjectMeta<Pod>> =
825            Api::default_namespaced(Client::new(mock_service, "default"));
826        let _ = pods.watch(&Default::default(), "0").await;
827        spawned.await.unwrap();
828    }
829
830    #[tokio::test]
831    async fn test_watch_error_response_is_surfaced() {
832        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
833        let spawned = tokio::spawn(async move {
834            let mut handle = pin!(handle);
835            let (_request, send) = handle.next_request().await.expect("service not called");
836            // apiservers pretty-print error bodies for some clients, so this can span several lines
837            let body = serde_json::to_vec_pretty(&serde_json::json!({
838                "kind": "Status",
839                "apiVersion": "v1",
840                "metadata": {},
841                "status": "Failure",
842                "message": "pods is forbidden",
843                "reason": "Forbidden",
844                "code": 403,
845            }))
846            .unwrap();
847            send.send_response(
848                Response::builder()
849                    .status(http::StatusCode::FORBIDDEN)
850                    .body(Body::from(body))
851                    .unwrap(),
852            );
853        });
854
855        let pods: Api<Pod> = Api::default_namespaced(Client::new(mock_service, "default"));
856        let Err(err) = pods.watch(&Default::default(), "0").await else {
857            panic!("watch with an error response should fail");
858        };
859        assert!(matches!(&err, Error::Api(s) if s.code == 403), "got {err:?}");
860        spawned.await.unwrap();
861    }
862
863    #[tokio::test]
864    async fn test_non_json_error_response_is_reconstructed() {
865        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
866        let spawned = tokio::spawn(async move {
867            let mut handle = pin!(handle);
868            let (_request, send) = handle.next_request().await.expect("service not called");
869            // Some routers in front of the apiserver (or a probe for a CRD
870            // that isn't installed yet) return a plain-text 404 instead of a
871            // JSON `Status`.
872            send.send_response(
873                Response::builder()
874                    .status(http::StatusCode::NOT_FOUND)
875                    .body(Body::from(b"404 page not found\n".to_vec()))
876                    .unwrap(),
877            );
878        });
879
880        let pods: Api<Pod> = Api::default_namespaced(Client::new(mock_service, "default"));
881        let Err(err) = pods.get("test").await else {
882            panic!("get with a non-JSON error response should fail");
883        };
884        let Error::Api(status) = &err else {
885            panic!("expected Error::Api, got {err:?}");
886        };
887        assert_eq!(status.code, 404);
888        assert_eq!(status.reason, "Failed to parse error data");
889        assert_eq!(status.message, "404 page not found");
890        spawned.await.unwrap();
891    }
892
893    #[tokio::test]
894    async fn watch_direct_unexpected_eof_delivers_added_then_ends() {
895        // Neutral wording: classification must use `ErrorKind`, not the
896        // rustls missing-close-notify message.
897        let body = body_from_chunks(vec![
898            Ok(pod_event_frame("ADDED", "11")),
899            Err(std::io::Error::new(ErrorKind::UnexpectedEof, "connection closed")),
900        ]);
901        let (spawned, stream) = open_watch(body).await;
902        let mut stream = pin!(stream);
903
904        expect_pod(next_event(&mut stream).await, "ADDED", "11");
905        expect_end(next_event(&mut stream).await);
906        spawned.await.unwrap();
907    }
908
909    #[tokio::test]
910    async fn watch_wrapped_unexpected_eof_delivers_modified_then_ends() {
911        let body = body_from_chunks(vec![
912            Ok(pod_event_frame("MODIFIED", "22")),
913            Err(nested_io(
914                &["outer transport interruption", "inner transport interruption"],
915                std::io::Error::new(ErrorKind::UnexpectedEof, "connection closed"),
916            )),
917        ]);
918        let (spawned, stream) = open_watch(body).await;
919        let mut stream = pin!(stream);
920
921        expect_pod(next_event(&mut stream).await, "MODIFIED", "22");
922        expect_end(next_event(&mut stream).await);
923        spawned.await.unwrap();
924    }
925
926    #[tokio::test]
927    async fn watch_unexpected_eof_before_events_ends() {
928        let body = body_from_chunks(vec![Err(nested_io(
929            &["outer transport interruption", "inner transport interruption"],
930            std::io::Error::new(ErrorKind::UnexpectedEof, "connection closed"),
931        ))]);
932        let (spawned, stream) = open_watch(body).await;
933        let mut stream = pin!(stream);
934
935        expect_end(next_event(&mut stream).await);
936        spawned.await.unwrap();
937    }
938
939    #[tokio::test]
940    async fn watch_unexpected_eof_after_truncated_line_keeps_resource_version() {
941        let mut payload = pod_event_json("ADDED", "33");
942        payload.push('\n');
943        payload.push_str(r#"{"type":"MODIFIED","object":{"apiVersion":"v1""#);
944        let body = body_from_chunks(vec![
945            Ok(Bytes::from(payload)),
946            Err(std::io::Error::new(ErrorKind::UnexpectedEof, "connection closed")),
947        ]);
948        let (spawned, stream) = open_watch(body).await;
949        let mut stream = pin!(stream);
950
951        expect_pod(next_event(&mut stream).await, "ADDED", "33");
952        expect_end(next_event(&mut stream).await);
953        spawned.await.unwrap();
954    }
955
956    #[tokio::test]
957    async fn watch_clean_end_after_truncated_line_keeps_resource_version() {
958        let mut payload = pod_event_json("MODIFIED", "34");
959        payload.push('\n');
960        payload.push_str(r#"{"type":"ADDED","object":{"apiVersion":"v1""#);
961        let body = body_from_chunks::<std::io::Error>(vec![Ok(Bytes::from(payload))]);
962        let (spawned, stream) = open_watch(body).await;
963        let mut stream = pin!(stream);
964
965        expect_pod(next_event(&mut stream).await, "MODIFIED", "34");
966        expect_end(next_event(&mut stream).await);
967        spawned.await.unwrap();
968    }
969
970    #[tokio::test]
971    async fn watch_clean_termination_delivers_every_event() {
972        let payload = format!("{}{}", pod_event_frame_str("ADDED", "1"), pod_event_frame_str("MODIFIED", "2"));
973        let body = body_from_chunks::<std::io::Error>(vec![Ok(Bytes::from(payload))]);
974        let (spawned, stream) = open_watch(body).await;
975        let mut stream = pin!(stream);
976
977        expect_pod(next_event(&mut stream).await, "ADDED", "1");
978        expect_pod(next_event(&mut stream).await, "MODIFIED", "2");
979        expect_end(next_event(&mut stream).await);
980        spawned.await.unwrap();
981    }
982
983    #[tokio::test]
984    async fn watch_chunk_decoder_eof_text_ends_stream() {
985        let body = body_from_chunks(vec![
986            Ok(pod_event_frame("ADDED", "44")),
987            Err(ChunkDecoderEof),
988        ]);
989        let (spawned, stream) = open_watch(body).await;
990        let mut stream = pin!(stream);
991
992        expect_pod(next_event(&mut stream).await, "ADDED", "44");
993        expect_end(next_event(&mut stream).await);
994        spawned.await.unwrap();
995    }
996
997    #[tokio::test]
998    async fn watch_malformed_json_is_serde_error() {
999        let body = body_from_chunks::<std::io::Error>(vec![Ok(Bytes::from(
1000            "{\"type\":\"ADDED\",\"object\":}\n",
1001        ))]);
1002        let (spawned, stream) = open_watch(body).await;
1003        let mut stream = pin!(stream);
1004
1005        match next_event(&mut stream).await {
1006            Some(Err(Error::SerdeError(err))) => assert!(!err.is_eof(), "{err}"),
1007            other => panic!("expected a non-eof serde error, got {other:?}"),
1008        }
1009        spawned.await.unwrap();
1010    }
1011
1012    #[tokio::test]
1013    async fn watch_nested_non_eof_io_error_is_read_events() {
1014        let body = body_from_chunks(vec![
1015            Ok(pod_event_frame("ADDED", "55")),
1016            Err(nested_io(
1017                &["outer transport interruption", "inner transport interruption"],
1018                std::io::Error::new(ErrorKind::ConnectionReset, "socket reset"),
1019            )),
1020        ]);
1021        let (spawned, stream) = open_watch(body).await;
1022        let mut stream = pin!(stream);
1023
1024        expect_pod(next_event(&mut stream).await, "ADDED", "55");
1025        let io_err = expect_read_events(next_event(&mut stream).await);
1026        assert_eq!(io_err.kind(), ErrorKind::Other);
1027        let retained = io_err.get_ref().expect("original body error is the source");
1028        let body_err = retained.downcast_ref::<Error>().expect("source is the body error");
1029        assert!(
1030            chain_has_io_kind(body_err, ErrorKind::ConnectionReset),
1031            "original source chain dropped ConnectionReset: {body_err:?}"
1032        );
1033        assert!(
1034            chain_has_message(body_err, "outer transport interruption"),
1035            "{body_err:?}"
1036        );
1037        assert!(
1038            chain_has_message(body_err, "inner transport interruption"),
1039            "{body_err:?}"
1040        );
1041        spawned.await.unwrap();
1042    }
1043
1044    #[tokio::test]
1045    async fn watch_close_notify_message_with_other_io_kind_is_read_events() {
1046        let body = body_from_chunks(vec![
1047            Ok(pod_event_frame("MODIFIED", "66")),
1048            Err(std::io::Error::new(
1049                ErrorKind::InvalidData,
1050                "peer closed connection without sending TLS close_notify",
1051            )),
1052        ]);
1053        let (spawned, stream) = open_watch(body).await;
1054        let mut stream = pin!(stream);
1055
1056        expect_pod(next_event(&mut stream).await, "MODIFIED", "66");
1057        let io_err = expect_read_events(next_event(&mut stream).await);
1058        assert_eq!(io_err.kind(), ErrorKind::Other);
1059        let retained = io_err.get_ref().expect("original body error is the source");
1060        let body_err = retained.downcast_ref::<Error>().expect("source is the body error");
1061        assert!(
1062            chain_has_message(body_err, "close_notify"),
1063            "fixture must mention close_notify, got {body_err}"
1064        );
1065        assert!(
1066            chain_has_io_kind(body_err, ErrorKind::InvalidData),
1067            "original InvalidData kind dropped: {body_err:?}"
1068        );
1069        assert!(
1070            !chain_has_io_kind(body_err, ErrorKind::UnexpectedEof),
1071            "a close_notify message with a different kind was treated as eof: {body_err:?}"
1072        );
1073        spawned.await.unwrap();
1074    }
1075
1076    fn pod_event_json(action: &str, resource_version: &str) -> String {
1077        serde_json::json!({
1078            "type": action,
1079            "object": {
1080                "apiVersion": "v1",
1081                "kind": "Pod",
1082                "metadata": {
1083                    "name": "test",
1084                    "namespace": "default",
1085                    "resourceVersion": resource_version,
1086                },
1087                "spec": {
1088                    "containers": [{ "name": "test", "image": "test-image" }],
1089                }
1090            }
1091        })
1092        .to_string()
1093    }
1094
1095    fn pod_event_frame_str(action: &str, resource_version: &str) -> String {
1096        format!("{}\n", pod_event_json(action, resource_version))
1097    }
1098
1099    fn pod_event_frame(action: &str, resource_version: &str) -> Bytes {
1100        Bytes::from(pod_event_frame_str(action, resource_version))
1101    }
1102
1103    fn body_from_chunks<E>(chunks: Vec<Result<Bytes, E>>) -> Body
1104    where
1105        E: std::error::Error + Send + Sync + 'static,
1106    {
1107        let frames = chunks.into_iter().map(|chunk| chunk.map(Frame::data));
1108        Body::wrap_body(StreamBody::new(futures::stream::iter(frames)))
1109    }
1110
1111    /// `Api::watch` calls `Client::request_events`.
1112    async fn open_watch(body: Body) -> (tokio::task::JoinHandle<()>, impl Stream<Item = Result<WatchEvent<Pod>>>) {
1113        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
1114        let spawned = tokio::spawn(async move {
1115            let mut handle = pin!(handle);
1116            let (request, send) = handle.next_request().await.expect("service not called");
1117            assert!(
1118                request.uri().query().unwrap_or_default().contains("watch=true"),
1119                "expected the client watch path, got {}",
1120                request.uri()
1121            );
1122            send.send_response(Response::new(body));
1123        });
1124        let pods: Api<Pod> = Api::default_namespaced(Client::new(mock_service, "default"));
1125        let stream = pods.watch(&Default::default(), "0").await.expect("watch response");
1126        (spawned, stream)
1127    }
1128
1129    async fn next_event<S>(stream: &mut S) -> Option<Result<WatchEvent<Pod>>>
1130    where
1131        S: Stream<Item = Result<WatchEvent<Pod>>> + Unpin,
1132    {
1133        tokio::time::timeout(WATCH_POLL_TIMEOUT, stream.next())
1134            .await
1135            .expect("watch stream stalled")
1136    }
1137
1138    fn expect_pod(item: Option<Result<WatchEvent<Pod>>>, action: &str, resource_version: &str) {
1139        let pod = match (action, item) {
1140            ("ADDED", Some(Ok(WatchEvent::Added(pod)))) => pod,
1141            ("MODIFIED", Some(Ok(WatchEvent::Modified(pod)))) => pod,
1142            (_, other) => panic!("expected {action} pod, got {other:?}"),
1143        };
1144        assert_eq!(pod.metadata.name.as_deref(), Some("test"));
1145        assert_eq!(
1146            pod.metadata.resource_version.as_deref(),
1147            Some(resource_version),
1148            "completed event must keep its resource version"
1149        );
1150    }
1151
1152    fn expect_end(item: Option<Result<WatchEvent<Pod>>>) {
1153        assert!(item.is_none(), "expected the watch stream to end, got {item:?}");
1154    }
1155
1156    fn expect_read_events(item: Option<Result<WatchEvent<Pod>>>) -> std::io::Error {
1157        match item {
1158            Some(Err(Error::ReadEvents(err))) => err,
1159            other => panic!("expected ReadEvents, got {other:?}"),
1160        }
1161    }
1162
1163    fn nested_io(messages: &[&'static str], io_err: std::io::Error) -> SourceChain {
1164        let (outer, rest) = messages.split_first().expect("at least one wrapper");
1165        let mut source: Box<dyn std::error::Error + Send + Sync> = Box::new(io_err);
1166        for message in rest.iter().rev() {
1167            source = Box::new(SourceChain {
1168                message,
1169                source: Some(source),
1170            });
1171        }
1172        SourceChain {
1173            message: outer,
1174            source: Some(source),
1175        }
1176    }
1177
1178    fn chain_has_io_kind(err: &(dyn std::error::Error + 'static), kind: ErrorKind) -> bool {
1179        let mut current = Some(err);
1180        for _ in 0..16 {
1181            let Some(err) = current else {
1182                return false;
1183            };
1184            if let Some(io_err) = err.downcast_ref::<std::io::Error>()
1185                && io_err.kind() == kind
1186            {
1187                return true;
1188            }
1189            current = err.source();
1190        }
1191        false
1192    }
1193
1194    fn chain_has_message(err: &(dyn std::error::Error + 'static), message: &str) -> bool {
1195        let mut current = Some(err);
1196        for _ in 0..16 {
1197            let Some(err) = current else {
1198                return false;
1199            };
1200            if err.to_string().contains(message) {
1201                return true;
1202            }
1203            current = err.source();
1204        }
1205        false
1206    }
1207
1208    /// Models a `source()` chain without matching on error text.
1209    struct SourceChain {
1210        message: &'static str,
1211        source: Option<Box<dyn std::error::Error + Send + Sync>>,
1212    }
1213
1214    impl fmt::Debug for SourceChain {
1215        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1216            f.debug_struct("SourceChain")
1217                .field("message", &self.message)
1218                .field("source", &self.source.as_ref().map(ToString::to_string))
1219                .finish()
1220        }
1221    }
1222
1223    impl fmt::Display for SourceChain {
1224        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1225            f.write_str(self.message)
1226        }
1227    }
1228
1229    impl std::error::Error for SourceChain {
1230        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1231            self.source.as_ref().map(|err| err.as_ref() as _)
1232        }
1233    }
1234
1235    /// Legacy chunk-decoder failure. Its display matches the existing text
1236    /// check and it is not an `UnexpectedEof` I/O error.
1237    #[derive(Debug)]
1238    struct ChunkDecoderEof;
1239
1240    impl fmt::Display for ChunkDecoderEof {
1241        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1242            f.write_str("unexpected EOF during chunk size line")
1243        }
1244    }
1245
1246    impl std::error::Error for ChunkDecoderEof {}
1247
1248    #[cfg(feature = "ws")]
1249    #[tokio::test]
1250    async fn connect_returns_api_error_for_forbidden_status() {
1251        let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
1252        let spawned = tokio::spawn(async move {
1253            let mut handle = pin!(handle);
1254            let (_request, send) = handle.next_request().await.expect("service not called");
1255            let body = serde_json::json!({
1256                "status": "Failure",
1257                "message": "pods \"my-pod\" is forbidden: User \"system:serviceaccount:default:my-sa\" cannot create resource \"pods/exec\"",
1258                "reason": "Forbidden",
1259                "code": 403
1260            });
1261            send.send_response(
1262                Response::builder()
1263                    .status(403)
1264                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
1265                    .unwrap(),
1266            );
1267        });
1268
1269        let client = Client::new(mock_service, "default");
1270        let request = Request::builder()
1271            .uri("/api/v1/namespaces/default/pods/my-pod/exec")
1272            .body(Vec::new())
1273            .unwrap();
1274        let err = match client.connect(request).await {
1275            Err(err) => err,
1276            Ok(_) => panic!("403 is an API error"),
1277        };
1278        match err {
1279            Error::Api(status) => {
1280                assert_eq!(status.code, 403);
1281                assert!(status.message.contains("forbidden"), "{}", status.message);
1282            }
1283            other => panic!("expected Api error, got {other}"),
1284        }
1285        spawned.await.unwrap();
1286    }
1287}