Skip to main content

qubit_http/response/
http_response.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! Unified HTTP response type and helpers.
11
12use std::sync::{
13    Arc,
14    Mutex,
15};
16use std::time::Duration;
17
18use async_stream::stream;
19use bytes::Bytes;
20use futures_util::{
21    stream as futures_stream,
22    StreamExt,
23};
24use http::header::{
25    CONTENT_LENGTH,
26    CONTENT_TYPE,
27};
28use http::{
29    HeaderMap,
30    Method,
31    StatusCode,
32};
33use serde::de::DeserializeOwned;
34use tokio_util::sync::CancellationToken;
35use url::Url;
36
37use crate::error::{
38    backend_error_mapper::map_reqwest_error,
39    ReqwestErrorPhase,
40};
41use crate::sse::{
42    DoneMarkerPolicy,
43    SseChunkStream,
44    SseEventStream,
45    SseJsonMode,
46};
47use crate::{
48    HttpByteStream,
49    HttpError,
50    HttpErrorKind,
51    HttpResult,
52};
53
54use super::{
55    HttpResponseMeta,
56    HttpResponseOptions,
57};
58
59/// Snapshot of a body read failure retained after the backend body is consumed.
60#[derive(Debug, Clone)]
61struct BodyReadFailure {
62    /// Error category from the original failure.
63    kind: HttpErrorKind,
64    /// Original failure message.
65    message: String,
66    /// Optional request method context.
67    method: Option<Method>,
68    /// Optional request URL context.
69    url: Option<Url>,
70    /// Optional HTTP status context.
71    status: Option<StatusCode>,
72    /// Optional body preview context.
73    response_body_preview: Option<String>,
74    /// Optional retry-after context.
75    retry_after: Option<Duration>,
76}
77
78impl BodyReadFailure {
79    /// Captures cloneable diagnostic fields from a read error.
80    ///
81    /// # Parameters
82    /// - `error`: Original body read failure.
83    ///
84    /// # Returns
85    /// Cloneable failure snapshot for subsequent reads.
86    fn from_error(error: &HttpError) -> Self {
87        Self {
88            kind: error.kind,
89            message: error.message.clone(),
90            method: error.method.clone(),
91            url: error.url.clone(),
92            status: error.status,
93            response_body_preview: error.response_body_preview.clone(),
94            retry_after: error.retry_after,
95        }
96    }
97
98    /// Rebuilds an [`HttpError`] for a later read attempt.
99    ///
100    /// # Returns
101    /// Error preserving the original kind and cloneable context.
102    fn to_error(&self) -> HttpError {
103        let mut error = HttpError::new(
104            self.kind,
105            format!("previous response body read failed: {}", self.message),
106        );
107        if let Some(method) = self.method.as_ref() {
108            error = error.with_method(method);
109        }
110        if let Some(url) = self.url.as_ref() {
111            error = error.with_url(url);
112        }
113        if let Some(status) = self.status {
114            error = error.with_status(status);
115        }
116        if let Some(preview) = self.response_body_preview.as_ref() {
117            error = error.with_response_body_preview(preview.clone());
118        }
119        if let Some(retry_after) = self.retry_after {
120            error = error.with_retry_after(retry_after);
121        }
122        error
123    }
124}
125
126/// Shared response body failure state.
127type BodyReadFailureState = Arc<Mutex<Option<BodyReadFailure>>>;
128
129/// Maps one backend response body read error into this crate's error model.
130///
131/// # Parameters
132/// - `error`: Backend error returned while reading response body bytes.
133/// - `method`: Request method used for diagnostics.
134/// - `url`: Request URL used for diagnostics.
135///
136/// # Returns
137/// [`HttpErrorKind::ReadTimeout`] for timeout errors, otherwise
138/// [`HttpErrorKind::Transport`] for backend body read failures.
139fn map_response_read_error(error: reqwest::Error, method: Method, url: Url) -> HttpError {
140    if error.is_timeout() {
141        return map_reqwest_error(
142            error,
143            HttpErrorKind::Transport,
144            ReqwestErrorPhase::Read,
145            Some(method),
146            Some(url),
147        );
148    }
149
150    HttpError::transport(format!("Failed to read response body: {}", error))
151        .with_method(&method)
152        .with_url(&url)
153        .with_source(error)
154}
155
156/// Runtime state bound to one response instance.
157#[derive(Debug, Clone)]
158struct HttpResponseRuntime {
159    /// Per-response read timeout inherited from request/client.
160    read_timeout: Duration,
161    /// Optional cancellation token inherited from request.
162    cancellation_token: Option<CancellationToken>,
163    /// Request URL used in read/cancellation error context.
164    request_url: Url,
165    /// First response body read failure, if the backend stream failed after
166    /// being taken from this response.
167    body_read_failure: BodyReadFailureState,
168}
169
170impl HttpResponseRuntime {
171    fn new(
172        read_timeout: Duration,
173        cancellation_token: Option<CancellationToken>,
174        request_url: Url,
175    ) -> Self {
176        Self {
177            read_timeout,
178            cancellation_token,
179            request_url,
180            body_read_failure: Arc::new(Mutex::new(None)),
181        }
182    }
183}
184
185/// Unified HTTP response with lazily consumed body.
186#[derive(Debug)]
187pub struct HttpResponse {
188    /// Response metadata (status, headers, final URL, request method).
189    pub meta: HttpResponseMeta,
190    /// Raw backend response until consumed.
191    backend: Option<reqwest::Response>,
192    /// Cached full body bytes after eager or lazy read.
193    buffered_body: Option<Bytes>,
194    /// Runtime state inherited from request/client.
195    runtime: HttpResponseRuntime,
196    /// Decode and error-preview options inherited from client options.
197    options: HttpResponseOptions,
198}
199
200impl HttpResponse {
201    /// Creates a buffered response.
202    pub fn new(
203        status: StatusCode,
204        headers: HeaderMap,
205        body: Bytes,
206        url: Url,
207        method: Method,
208    ) -> Self {
209        Self {
210            meta: HttpResponseMeta::new(status, headers, url.clone(), method),
211            backend: None,
212            buffered_body: Some(body),
213            runtime: HttpResponseRuntime::new(Duration::from_secs(30), None, url),
214            options: HttpResponseOptions::default(),
215        }
216    }
217
218    /// Creates a response from backend response and request-scoped options.
219    pub(crate) fn from_backend(
220        meta: HttpResponseMeta,
221        backend: reqwest::Response,
222        read_timeout: Duration,
223        cancellation_token: Option<CancellationToken>,
224        request_url: Url,
225        options: HttpResponseOptions,
226    ) -> Self {
227        Self {
228            meta,
229            backend: Some(backend),
230            buffered_body: None,
231            runtime: HttpResponseRuntime::new(read_timeout, cancellation_token, request_url),
232            options,
233        }
234    }
235
236    /// Returns shared response metadata.
237    #[inline]
238    pub fn meta(&self) -> &HttpResponseMeta {
239        &self.meta
240    }
241
242    /// Returns response status code.
243    #[inline]
244    pub fn status(&self) -> StatusCode {
245        self.meta.status
246    }
247
248    /// Returns response headers.
249    #[inline]
250    pub fn headers(&self) -> &HeaderMap {
251        &self.meta.headers
252    }
253
254    /// Returns final response URL.
255    #[inline]
256    pub fn url(&self) -> &Url {
257        &self.meta.url
258    }
259
260    /// Returns request URL used in response read context.
261    #[inline]
262    pub fn request_url(&self) -> &Url {
263        &self.runtime.request_url
264    }
265
266    /// Returns whether status is success.
267    #[inline]
268    pub fn is_success(&self) -> bool {
269        self.status().is_success()
270    }
271
272    /// Returns parsed `Retry-After` hint when status and headers provide one.
273    #[inline]
274    pub fn retry_after_hint(&self) -> Option<Duration> {
275        self.meta.retry_after_hint()
276    }
277
278    /// Returns a previous body read failure, if any.
279    ///
280    /// # Returns
281    /// `Some(HttpError)` when an earlier body read failed or the failure-state
282    /// lock is poisoned; otherwise `None`.
283    fn previous_body_read_error(&self) -> Option<HttpError> {
284        match self.runtime.body_read_failure.lock() {
285            Ok(guard) => guard.as_ref().map(BodyReadFailure::to_error),
286            Err(_) => Some(
287                HttpError::other("Response body read failure state lock poisoned")
288                    .with_method(&self.meta.method)
289                    .with_url(&self.runtime.request_url),
290            ),
291        }
292    }
293
294    /// Stores the first body read failure for later read attempts.
295    ///
296    /// # Parameters
297    /// - `error`: Error produced while reading the response body.
298    fn remember_body_read_failure(&self, error: &HttpError) {
299        Self::remember_body_read_failure_state(&self.runtime.body_read_failure, error);
300    }
301
302    /// Stores the first body read failure in a shared state holder.
303    ///
304    /// # Parameters
305    /// - `state`: Shared failure state captured by response streams.
306    /// - `error`: Error produced while reading the response body.
307    fn remember_body_read_failure_state(state: &BodyReadFailureState, error: &HttpError) {
308        if let Ok(mut guard) = state.lock() {
309            if guard.is_none() {
310                *guard = Some(BodyReadFailure::from_error(error));
311            }
312        }
313    }
314
315    /// Returns `Ok(self)` for success statuses, otherwise maps a status error
316    /// with `Retry-After` and response-body preview context.
317    pub(crate) async fn into_success_or_status_error(
318        self,
319        message_prefix: &str,
320    ) -> HttpResult<Self> {
321        let status = self.status();
322        if status.is_success() {
323            return Ok(self);
324        }
325        let retry_after = self.retry_after_hint();
326        let method = self.meta.method.clone();
327        let url = self.request_url().clone();
328        let error_preview_limit = self.options.error_response_preview_limit;
329        let body_preview = self.into_error_body_preview(error_preview_limit).await?;
330        let message = format!(
331            "{} with status {} for {} {}; response body preview: {}",
332            message_prefix, status, method, url, body_preview
333        );
334        let mut mapped = HttpError::status(status, message)
335            .with_method(&method)
336            .with_url(&url)
337            .with_response_body_preview(body_preview);
338        if let Some(retry_after) = retry_after {
339            mapped = mapped.with_retry_after(retry_after);
340        }
341        Err(mapped)
342    }
343
344    /// Consumes this response and returns a bounded body preview for status errors.
345    ///
346    /// # Errors
347    /// Returns [`HttpErrorKind::Cancelled`](crate::HttpErrorKind::Cancelled)
348    /// when the request cancellation token fires while preview bytes are being
349    /// read.
350    pub(crate) async fn into_error_body_preview(mut self, max_bytes: usize) -> HttpResult<String> {
351        let limit = max_bytes.max(1);
352        if let Some(error) = self.cancelled_error_if_needed(
353            "Request cancelled while reading status error response body preview",
354        ) {
355            return Err(error);
356        }
357        if let Some(body) = self.buffered_body.take() {
358            let end = body.len().min(limit);
359            return Ok(Self::render_error_body_preview(
360                &body[..end],
361                body.len() > limit,
362            ));
363        }
364        let Some(backend) = self.backend.take() else {
365            return Ok("<empty>".to_string());
366        };
367        Self::read_error_body_preview(
368            backend,
369            self.runtime.read_timeout,
370            self.runtime.cancellation_token.clone(),
371            self.meta.method.clone(),
372            self.runtime.request_url.clone(),
373            limit,
374        )
375        .await
376    }
377
378    /// Returns full body bytes, consuming backend stream lazily on first call.
379    pub async fn bytes(&mut self) -> HttpResult<Bytes> {
380        if let Some(body) = &self.buffered_body {
381            return Ok(body.clone());
382        }
383        if let Some(error) = self.previous_body_read_error() {
384            return Err(error);
385        }
386        let Some(mut backend) = self.backend.take() else {
387            self.buffered_body = Some(Bytes::new());
388            return Ok(Bytes::new());
389        };
390
391        let method = self.meta.method.clone();
392        let url = self.runtime.request_url.clone();
393        let read_timeout = self.runtime.read_timeout;
394        let cancellation_token = self.runtime.cancellation_token.clone();
395        let mut body = bytes::BytesMut::new();
396
397        loop {
398            let next = if let Some(token) = &cancellation_token {
399                tokio::select! {
400                    _ = token.cancelled() => {
401                        let error = HttpError::cancelled("Request cancelled while reading response body")
402                            .with_method(&method)
403                            .with_url(&url);
404                        self.remember_body_read_failure(&error);
405                        return Err(error);
406                    }
407                    item = tokio::time::timeout(read_timeout, backend.chunk()) => item,
408                }
409            } else {
410                tokio::time::timeout(read_timeout, backend.chunk()).await
411            };
412
413            match next {
414                Ok(Ok(Some(chunk))) => body.extend_from_slice(&chunk),
415                Ok(Ok(None)) => {
416                    let body = body.freeze();
417                    self.buffered_body = Some(body.clone());
418                    return Ok(body);
419                }
420                Ok(Err(error)) => {
421                    let error = map_response_read_error(error, method, url);
422                    self.remember_body_read_failure(&error);
423                    return Err(error);
424                }
425                Err(_) => {
426                    let error = HttpError::read_timeout(format!(
427                        "Read timeout after {:?} while reading response body",
428                        read_timeout
429                    ))
430                    .with_method(&self.meta.method)
431                    .with_url(&self.runtime.request_url);
432                    self.remember_body_read_failure(&error);
433                    return Err(error);
434                }
435            }
436        }
437    }
438
439    /// Returns body as stream; if already buffered, returns stream backed by cached bytes.
440    pub fn stream(&mut self) -> HttpResult<HttpByteStream> {
441        if let Some(body) = self.buffered_body.as_ref() {
442            let bytes = body.clone();
443            return Ok(Box::pin(futures_stream::once(async move { Ok(bytes) })));
444        }
445        if let Some(error) = self.previous_body_read_error() {
446            return Err(error);
447        }
448        if let Some(error) = self
449            .cancelled_error_if_needed("Streaming response cancelled before reading response body")
450        {
451            return Err(error);
452        }
453        let Some(backend) = self.backend.take() else {
454            return Ok(Box::pin(futures_stream::empty()));
455        };
456
457        let method = self.meta.method.clone();
458        let url = self.runtime.request_url.clone();
459        let read_timeout = self.runtime.read_timeout;
460        let cancellation_token = self.runtime.cancellation_token.clone();
461        let body_read_failure = self.runtime.body_read_failure.clone();
462        let mut stream = backend.bytes_stream();
463        let wrapped = stream! {
464            loop {
465                let next = if let Some(token) = &cancellation_token {
466                    tokio::select! {
467                        _ = token.cancelled() => {
468                            let error = HttpError::cancelled("Streaming response cancelled while reading body")
469                                .with_method(&method)
470                                .with_url(&url);
471                            Self::remember_body_read_failure_state(&body_read_failure, &error);
472                            yield Err(error);
473                            break;
474                        }
475                        item = tokio::time::timeout(read_timeout, stream.next()) => item,
476                    }
477                } else {
478                    tokio::time::timeout(read_timeout, stream.next()).await
479                };
480                match next {
481                    Ok(Some(Ok(bytes))) => yield Ok(bytes),
482                    Ok(Some(Err(error))) => {
483                        let mapped = map_response_read_error(error, method.clone(), url.clone());
484                        Self::remember_body_read_failure_state(&body_read_failure, &mapped);
485                        yield Err(mapped);
486                        break;
487                    }
488                    Ok(None) => break,
489                    Err(_) => {
490                        let error = HttpError::read_timeout(format!(
491                            "Read timeout after {:?} while streaming response",
492                            read_timeout
493                        ))
494                        .with_method(&method)
495                        .with_url(&url);
496                        Self::remember_body_read_failure_state(&body_read_failure, &error);
497                        yield Err(error);
498                        break;
499                    }
500                }
501            }
502        };
503        Ok(Box::pin(wrapped))
504    }
505
506    /// Interprets response body as UTF-8 text.
507    pub async fn text(&mut self) -> HttpResult<String> {
508        let body = self.bytes().await?;
509        String::from_utf8(body.to_vec()).map_err(|error| {
510            HttpError::decode(format!(
511                "Failed to decode response body as UTF-8: {}",
512                error
513            ))
514            .with_status(self.meta.status)
515            .with_url(&self.meta.url)
516        })
517    }
518
519    /// Deserializes response body as JSON.
520    pub async fn json<T>(&mut self) -> HttpResult<T>
521    where
522        T: DeserializeOwned,
523    {
524        let body = self.bytes().await?;
525        serde_json::from_slice(&body).map_err(|error| {
526            HttpError::decode(format!("Failed to decode response JSON: {}", error))
527                .with_status(self.meta.status)
528                .with_url(&self.meta.url)
529        })
530    }
531
532    /// Overrides the maximum allowed size (in bytes) for one SSE line on this response.
533    ///
534    /// Values below 1 are clamped to 1. Returns `self` so callers can chain configuration
535    /// before consuming the body with [`Self::sse_events`] or [`Self::sse_chunks`]
536    /// (together with [`Self::sse_json_mode`], [`Self::sse_done_marker_policy`], etc.).
537    #[inline]
538    pub fn sse_max_line_bytes(mut self, max_line_bytes: usize) -> Self {
539        self.options.sse_max_line_bytes = max_line_bytes.max(1);
540        self
541    }
542
543    /// Overrides the maximum allowed size (in bytes) for one SSE frame on this response.
544    ///
545    /// Values below 1 are clamped to 1. Returns `self` for chained configuration.
546    #[inline]
547    pub fn sse_max_frame_bytes(mut self, max_frame_bytes: usize) -> Self {
548        self.options.sse_max_frame_bytes = max_frame_bytes.max(1);
549        self
550    }
551
552    /// Overrides the JSON decoding mode used by [`Self::sse_chunks`] on this response.
553    #[inline]
554    pub fn sse_json_mode(mut self, mode: SseJsonMode) -> Self {
555        self.options.sse_json_mode = mode;
556        self
557    }
558
559    /// Overrides how [`Self::sse_chunks`] detects end-of-stream from trimmed `data:` payloads.
560    #[inline]
561    pub fn sse_done_marker_policy(mut self, policy: DoneMarkerPolicy) -> Self {
562        self.options.sse_done_marker_policy = policy;
563        self
564    }
565
566    /// Decodes body stream as SSE events using this response's SSE line/frame byte limits (from
567    /// client defaults unless overridden via [`Self::sse_max_line_bytes`] /
568    /// [`Self::sse_max_frame_bytes`]).
569    pub fn sse_events(mut self) -> SseEventStream {
570        let max_line_bytes = self.options.sse_max_line_bytes;
571        let max_frame_bytes = self.options.sse_max_frame_bytes;
572        match self.stream() {
573            Ok(stream) => crate::sse::decode_events_from_stream_with_limits(
574                stream,
575                max_line_bytes,
576                max_frame_bytes,
577            ),
578            Err(error) => Box::pin(futures_stream::once(async move { Err(error) })),
579        }
580    }
581
582    /// Decodes SSE `data:` lines as JSON chunks using this response's SSE JSON mode, done-marker
583    /// policy, and line/frame limits (see [`Self::sse_json_mode`], [`Self::sse_done_marker_policy`],
584    /// [`Self::sse_max_line_bytes`], [`Self::sse_max_frame_bytes`]).
585    pub fn sse_chunks<T>(mut self) -> SseChunkStream<T>
586    where
587        T: DeserializeOwned + Send + 'static,
588    {
589        let done_policy = self.options.sse_done_marker_policy.clone();
590        let mode = self.options.sse_json_mode;
591        let max_line_bytes = self.options.sse_max_line_bytes;
592        let max_frame_bytes = self.options.sse_max_frame_bytes;
593        match self.stream() {
594            Ok(stream) => crate::sse::decode_json_chunks_from_stream_with_limits(
595                stream,
596                done_policy,
597                mode,
598                max_line_bytes,
599                max_frame_bytes,
600            ),
601            Err(error) => Box::pin(futures_stream::once(async move { Err(error) })),
602        }
603    }
604
605    /// Returns a buffered body reference for response logging if available.
606    ///
607    /// # Returns
608    /// `Some(&Bytes)` when response body has already been buffered.
609    pub(crate) fn buffered_body_for_logging(&self) -> Option<&Bytes> {
610        self.buffered_body.as_ref()
611    }
612
613    /// Returns whether logger may safely buffer the full body for logging.
614    ///
615    /// # Parameters
616    /// - `body_log_limit`: Configured logging body preview limit in bytes.
617    ///
618    /// # Returns
619    /// `true` only when this response is not SSE, has an explicit `Content-Length`,
620    /// and declared length is within `body_log_limit`.
621    pub(crate) fn can_buffer_body_for_logging(&self, body_log_limit: usize) -> bool {
622        if self.backend.is_none() {
623            return false;
624        }
625        if self.is_sse_response() {
626            return false;
627        }
628        self.content_length_hint()
629            .is_some_and(|content_length| content_length <= body_log_limit as u64)
630    }
631
632    /// Reads bounded preview bytes from a response body for status error messages.
633    ///
634    /// # Errors
635    /// Returns [`HttpErrorKind::Cancelled`](crate::HttpErrorKind::Cancelled)
636    /// when the supplied cancellation token fires while waiting for preview
637    /// bytes.
638    async fn read_error_body_preview(
639        mut response: reqwest::Response,
640        read_timeout: Duration,
641        cancellation_token: Option<CancellationToken>,
642        method: Method,
643        url: Url,
644        max_bytes: usize,
645    ) -> HttpResult<String> {
646        let limit = max_bytes.max(1);
647        let mut preview = Vec::new();
648        let mut truncated = false;
649
650        loop {
651            let next = if let Some(token) = cancellation_token.as_ref() {
652                tokio::select! {
653                    _ = token.cancelled() => {
654                        return Err(HttpError::cancelled(
655                            "Request cancelled while reading status error response body preview",
656                        )
657                        .with_method(&method)
658                        .with_url(&url));
659                    }
660                    item = tokio::time::timeout(read_timeout, response.chunk()) => item,
661                }
662            } else {
663                tokio::time::timeout(read_timeout, response.chunk()).await
664            };
665            match next {
666                Ok(Ok(Some(chunk))) => {
667                    if preview.len() >= limit {
668                        truncated = true;
669                        break;
670                    }
671                    let remaining = limit - preview.len();
672                    if chunk.len() > remaining {
673                        preview.extend_from_slice(&chunk[..remaining]);
674                        truncated = true;
675                        break;
676                    }
677                    preview.extend_from_slice(&chunk);
678                }
679                Ok(Ok(None)) => break,
680                Ok(Err(error)) => {
681                    return Ok(format!(
682                        "<error body unavailable: failed to read response body: {}>",
683                        error
684                    ));
685                }
686                Err(_) => {
687                    return Ok(format!(
688                        "<error body unavailable: read timeout after {:?}>",
689                        read_timeout
690                    ));
691                }
692            }
693        }
694        Ok(Self::render_error_body_preview(&preview, truncated))
695    }
696
697    /// Returns a cancellation error with response read context when cancelled.
698    ///
699    /// # Parameters
700    /// - `message`: Cancellation message to include in the error.
701    ///
702    /// # Returns
703    /// `Some(HttpError)` when this response has a cancelled token; otherwise
704    /// `None`.
705    fn cancelled_error_if_needed(&self, message: &str) -> Option<HttpError> {
706        if self
707            .runtime
708            .cancellation_token
709            .as_ref()
710            .is_some_and(CancellationToken::is_cancelled)
711        {
712            Some(
713                HttpError::cancelled(message.to_string())
714                    .with_method(&self.meta.method)
715                    .with_url(&self.runtime.request_url),
716            )
717        } else {
718            None
719        }
720    }
721
722    /// Returns `Content-Length` parsed from response headers when present and valid.
723    fn content_length_hint(&self) -> Option<u64> {
724        self.meta
725            .headers
726            .get(CONTENT_LENGTH)
727            .and_then(|value| value.to_str().ok())
728            .and_then(|value| value.parse::<u64>().ok())
729    }
730
731    /// Returns whether response content-type is SSE (`text/event-stream`).
732    fn is_sse_response(&self) -> bool {
733        self.meta
734            .headers
735            .get(CONTENT_TYPE)
736            .and_then(|value| value.to_str().ok())
737            .is_some_and(|content_type| {
738                content_type
739                    .to_ascii_lowercase()
740                    .starts_with("text/event-stream")
741            })
742    }
743
744    fn render_error_body_preview(bytes: &[u8], truncated: bool) -> String {
745        if bytes.is_empty() {
746            return "<empty>".to_string();
747        }
748        let suffix = if truncated { "...<truncated>" } else { "" };
749        match std::str::from_utf8(bytes) {
750            Ok(text) => format!("{text}{suffix}"),
751            Err(_) => format!("<binary {} bytes>{suffix}", bytes.len()),
752        }
753    }
754}