Skip to main content

uarp_sdk/
client.rs

1//! The HTTP client: configuration, auth, retries, idempotency, error mapping.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use bytes::Bytes;
8use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
9use reqwest::{Method, RequestBuilder};
10use serde::de::DeserializeOwned;
11use serde::Serialize;
12use url::Url;
13
14use crate::error::{ApiError, Error, Problem, Result};
15use crate::generated::meta::DEFAULT_BASE_URL;
16use crate::sse::{EventStream, StreamOptions};
17use crate::util::encode_query_component;
18
19/// Placeholder for "this request has no query string".
20pub const NO_QUERY: Option<&()> = None;
21/// Placeholder for "this request has no body".
22pub const NO_BODY: Option<&()> = None;
23
24const RETRYABLE: [u16; 7] = [408, 409, 429, 500, 502, 503, 504];
25
26/// Per-call overrides.
27///
28/// Rust has no default arguments, so rather than add an options parameter to
29/// every generated method these are carried by a cheap clone of the client:
30/// `client.with_idempotency_key("order-4711").agents().create(&body)`.
31#[derive(Debug, Clone, Default)]
32pub struct RequestOptions {
33    /// Overrides the client timeout for calls made through this clone.
34    pub timeout: Option<Duration>,
35    /// Overrides the client retry budget.
36    pub max_retries: Option<u32>,
37    /// Reuse a specific key, e.g. to safely replay a create.
38    pub idempotency_key: Option<String>,
39    /// Headers added to every request.
40    pub extra_headers: Vec<(String, String)>,
41    /// Query parameters added to every request.
42    pub extra_query: Vec<(String, String)>,
43    /// Reconnection behaviour for event streams.
44    pub stream: Option<StreamOptions>,
45}
46
47/// What a generated method hands to the transport.
48#[derive(Debug)]
49pub struct Request<'a, Q: ?Sized = (), B: ?Sized = ()> {
50    pub method: Method,
51    pub path: String,
52    pub query: Option<&'a Q>,
53    pub body: Option<&'a B>,
54    pub headers: Vec<(&'static str, String)>,
55    /// Adds an `Idempotency-Key`, which also makes the write safe to retry.
56    pub idempotent: bool,
57}
58
59pub(crate) struct Inner {
60    pub(crate) http: reqwest::Client,
61    pub(crate) base_url: Url,
62    pub(crate) api_key: String,
63    pub(crate) max_retries: u32,
64    pub(crate) timeout: Duration,
65    pub(crate) user_agent: String,
66    pub(crate) default_headers: HeaderMap,
67    pub(crate) sse_token_in_query: bool,
68}
69
70/// Client for the UARP platform API.
71///
72/// Cloning is cheap: every clone shares one connection pool.
73///
74/// ```no_run
75/// # async fn demo() -> Result<(), uarp_sdk::Error> {
76/// let client = uarp_sdk::Client::from_env()?;
77/// let page = client.agents().list(&Default::default()).await?;
78/// # Ok(()) }
79/// ```
80#[derive(Clone)]
81pub struct Client {
82    pub(crate) inner: Arc<Inner>,
83    /// Overrides for calls made through this clone; the connection pool in
84    /// `inner` is shared with the client it came from.
85    pub(crate) options: RequestOptions,
86}
87
88impl std::fmt::Debug for Client {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("Client")
91            .field("base_url", &self.inner.base_url.as_str())
92            .field("max_retries", &self.inner.max_retries)
93            .field("timeout", &self.inner.timeout)
94            .field("options", &self.options)
95            .finish_non_exhaustive()
96    }
97}
98
99impl Client {
100    /// Build a client for the production endpoint with the given API key.
101    pub fn new(api_key: impl Into<String>) -> Result<Self> {
102        ClientBuilder::new().api_key(api_key).build()
103    }
104
105    /// Read the API key from `UARP_API_KEY` (or `SNAGA_API_KEY`) and the base
106    /// URL from `UARP_BASE_URL`.
107    pub fn from_env() -> Result<Self> {
108        // A set-but-empty variable is the environment's version of the mistake
109        // an omitted key is, so it is refused here rather than quietly building
110        // a credential-less client. Going keyless is a deliberate act:
111        // `.api_key("")` on the builder, never an empty env var.
112        let api_key = std::env::var("UARP_API_KEY")
113            .or_else(|_| std::env::var("SNAGA_API_KEY"))
114            .ok()
115            .filter(|key| !key.is_empty())
116            .ok_or_else(|| Error::Config("UARP_API_KEY is not set".into()))?;
117        let mut builder = ClientBuilder::new().api_key(api_key);
118        if let Ok(base) = std::env::var("UARP_BASE_URL") {
119            builder = builder.base_url(base);
120        }
121        builder.build()
122    }
123
124    pub fn builder() -> ClientBuilder {
125        ClientBuilder::new()
126    }
127
128    pub fn base_url(&self) -> &Url {
129        &self.inner.base_url
130    }
131
132    // ------------------------------------------------------- per-call options
133
134    /// A clone of this client that applies `options` to every call made
135    /// through it. The connection pool is shared, so this is cheap.
136    pub fn with_options(&self, options: RequestOptions) -> Client {
137        Client {
138            inner: self.inner.clone(),
139            options,
140        }
141    }
142
143    /// Reuse a specific idempotency key, e.g. to safely replay a create.
144    pub fn with_idempotency_key(&self, key: impl Into<String>) -> Client {
145        let mut options = self.options.clone();
146        options.idempotency_key = Some(key.into());
147        self.with_options(options)
148    }
149
150    pub fn with_timeout(&self, timeout: Duration) -> Client {
151        let mut options = self.options.clone();
152        options.timeout = Some(timeout);
153        self.with_options(options)
154    }
155
156    pub fn with_max_retries(&self, retries: u32) -> Client {
157        let mut options = self.options.clone();
158        options.max_retries = Some(retries);
159        self.with_options(options)
160    }
161
162    /// Add a header to every request made through the returned client.
163    pub fn with_header(&self, name: impl Into<String>, value: impl Into<String>) -> Client {
164        let mut options = self.options.clone();
165        options.extra_headers.push((name.into(), value.into()));
166        self.with_options(options)
167    }
168
169    /// Add a query parameter to every request made through the returned client.
170    pub fn with_query(&self, name: impl Into<String>, value: impl Into<String>) -> Client {
171        let mut options = self.options.clone();
172        options.extra_query.push((name.into(), value.into()));
173        self.with_options(options)
174    }
175
176    /// Reconnection behaviour for event streams opened through this clone.
177    pub fn with_stream_options(&self, stream: StreamOptions) -> Client {
178        let mut options = self.options.clone();
179        options.stream = Some(stream);
180        self.with_options(options)
181    }
182
183    // ------------------------------------------------------------ transport
184
185    /// Send a request and decode a JSON response body.
186    pub async fn request_json<Q, B, R>(&self, req: Request<'_, Q, B>) -> Result<R>
187    where
188        Q: Serialize + ?Sized + Sync,
189        B: Serialize + ?Sized + Sync,
190        R: DeserializeOwned,
191    {
192        let body = req.body;
193        let response = self
194            .run(&req, &move |builder: RequestBuilder| match body {
195                Some(value) => Ok(builder.json(value)),
196                None => Ok(builder),
197            })
198            .await?;
199        decode_json(response).await
200    }
201
202    /// Send a request and discard the response body.
203    pub async fn request_empty<Q, B>(&self, req: Request<'_, Q, B>) -> Result<()>
204    where
205        Q: Serialize + ?Sized + Sync,
206        B: Serialize + ?Sized + Sync,
207    {
208        let body = req.body;
209        self.run(&req, &move |builder: RequestBuilder| match body {
210            Some(value) => Ok(builder.json(value)),
211            None => Ok(builder),
212        })
213        .await?;
214        Ok(())
215    }
216
217    /// Send a request and return the raw response bytes (file downloads).
218    pub async fn request_bytes<Q, B>(&self, req: Request<'_, Q, B>) -> Result<Bytes>
219    where
220        Q: Serialize + ?Sized + Sync,
221        B: Serialize + ?Sized + Sync,
222    {
223        let body = req.body;
224        let response = self
225            .run(&req, &move |builder: RequestBuilder| match body {
226                Some(value) => Ok(builder.json(value)),
227                None => Ok(builder),
228            })
229            .await?;
230        response.bytes().await.map_err(Error::Connection)
231    }
232
233    /// Send a request and return the response body as text.
234    ///
235    /// Separate from `request_json` because a text payload is not JSON that
236    /// happens to be a string: deserialising JSONL or CSS into a `String`
237    /// fails, and on the one input where it would succeed — a body that is a
238    /// single quoted JSON string — it would silently strip the quotes.
239    pub async fn request_text<Q, B>(&self, req: Request<'_, Q, B>) -> Result<String>
240    where
241        Q: Serialize + ?Sized + Sync,
242        B: Serialize + ?Sized + Sync,
243    {
244        let body = req.body;
245        let response = self
246            .run(&req, &move |builder: RequestBuilder| match body {
247                Some(value) => Ok(builder.json(value)),
248                None => Ok(builder),
249            })
250            .await?;
251        response.text().await.map_err(Error::Connection)
252    }
253
254    /// Send a `multipart/form-data` request. The form is rebuilt for each retry.
255    pub async fn request_multipart<Q, R, F>(
256        &self,
257        req: Request<'_, Q, ()>,
258        make_form: F,
259    ) -> Result<R>
260    where
261        Q: Serialize + ?Sized + Sync,
262        R: DeserializeOwned,
263        F: Fn() -> Result<reqwest::multipart::Form> + Send + Sync,
264    {
265        let response = self
266            .run(&req, &move |builder: RequestBuilder| {
267                Ok(builder.multipart(make_form()?))
268            })
269            .await?;
270        decode_json(response).await
271    }
272
273    /// Open a server-sent event stream.
274    pub fn request_stream<Q>(
275        &self,
276        path: &str,
277        query: Option<&Q>,
278        headers: Vec<(&'static str, String)>,
279    ) -> EventStream
280    where
281        Q: Serialize + ?Sized,
282    {
283        let options = self.options.stream.clone().unwrap_or_default();
284        let mut headers: Vec<(String, String)> = headers
285            .into_iter()
286            .map(|(name, value)| (name.to_string(), value))
287            .collect();
288        headers.extend(self.options.extra_headers.iter().cloned());
289        let url = self.build_url(path, query).map(|mut url| {
290            // A keyless client has no token to put in the query either, and
291            // `?token=` empty is a credential the server then rejects.
292            if self.inner.sse_token_in_query && !self.inner.api_key.is_empty() {
293                url.query_pairs_mut()
294                    .append_pair("token", &self.inner.api_key);
295            }
296            url
297        });
298        EventStream::new(self.inner.clone(), url, headers, options)
299    }
300
301    /// Escape hatch for endpoints the generated surface does not cover.
302    pub async fn raw<R: DeserializeOwned>(
303        &self,
304        method: Method,
305        path: &str,
306        body: Option<&serde_json::Value>,
307    ) -> Result<R> {
308        let idempotent = method != Method::GET && path.starts_with("/api/v1");
309        self.request_json(Request {
310            method,
311            path: path.to_string(),
312            query: NO_QUERY,
313            body,
314            headers: Vec::new(),
315            idempotent,
316        })
317        .await
318    }
319
320    // -------------------------------------------------------------- private
321
322    async fn run<Q, B>(
323        &self,
324        req: &Request<'_, Q, B>,
325        apply_body: &(dyn Fn(RequestBuilder) -> Result<RequestBuilder> + Send + Sync),
326    ) -> Result<reqwest::Response>
327    where
328        Q: Serialize + ?Sized + Sync,
329        B: Serialize + ?Sized + Sync,
330    {
331        let url = self.build_url(&req.path, req.query)?;
332        let idempotency_key = req.idempotent.then(|| {
333            self.options
334                .idempotency_key
335                .clone()
336                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
337        });
338        let retryable_method = req.method == Method::GET || req.method == Method::HEAD;
339        let can_retry = retryable_method || idempotency_key.is_some();
340
341        let retries = self.options.max_retries.unwrap_or(self.inner.max_retries);
342        let timeout = self.options.timeout.unwrap_or(self.inner.timeout);
343
344        let mut attempt: u32 = 0;
345        loop {
346            let mut builder = self
347                .inner
348                .http
349                .request(req.method.clone(), url.clone())
350                .timeout(timeout)
351                .header(reqwest::header::ACCEPT, "application/json")
352                .header(reqwest::header::USER_AGENT, &self.inner.user_agent)
353                .headers(self.inner.default_headers.clone());
354
355            // An empty key means "no credentials" — the client's credentials
356            // travel another way, or it is a guest/public client. `Bearer `
357            // with nothing after it is NOT the same as sending no header: a
358            // server that validates the value can refuse it. TypeScript and
359            // Swift already draw this distinction; this keeps the family
360            // consistent for anyone who builds a client with `.api_key("")`.
361            if !self.inner.api_key.is_empty() {
362                builder = builder.header(
363                    reqwest::header::AUTHORIZATION,
364                    format!("Bearer {}", self.inner.api_key),
365                );
366            }
367
368            for (name, value) in &req.headers {
369                builder = builder.header(*name, value);
370            }
371            for (name, value) in &self.options.extra_headers {
372                builder = builder.header(name.as_str(), value);
373            }
374            if let Some(key) = &idempotency_key {
375                builder = builder.header("Idempotency-Key", key);
376            }
377            builder = apply_body(builder)?;
378
379            match builder.send().await {
380                Ok(response) if response.status().is_success() => return Ok(response),
381                Ok(response) => {
382                    let status = response.status().as_u16();
383                    let headers = collect_headers(response.headers());
384                    let retry_after = parse_retry_after(&headers);
385                    let should_retry = RETRYABLE.contains(&status)
386                        && headers.get("x-should-retry").map(String::as_str) != Some("false")
387                        && can_retry
388                        && attempt < retries;
389                    if !should_retry {
390                        let problem = read_problem(response).await;
391                        return Err(ApiError {
392                            status,
393                            problem,
394                            headers,
395                        }
396                        .into());
397                    }
398                    let wait = retry_after.unwrap_or_else(|| backoff(attempt));
399                    attempt += 1;
400                    tokio::time::sleep(wait.min(Duration::from_secs(60))).await;
401                }
402                Err(err) => {
403                    let mapped = if err.is_timeout() {
404                        Error::Timeout
405                    } else {
406                        Error::Connection(err)
407                    };
408                    if !can_retry || attempt >= retries {
409                        return Err(mapped);
410                    }
411                    let wait = backoff(attempt);
412                    attempt += 1;
413                    tokio::time::sleep(wait).await;
414                }
415            }
416        }
417    }
418
419    fn build_url<Q: Serialize + ?Sized>(&self, path: &str, query: Option<&Q>) -> Result<Url> {
420        let mut url = self
421            .inner
422            .base_url
423            .join(path.trim_start_matches('/'))
424            .map_err(|err| Error::Config(format!("invalid path {path}: {err}")))?;
425        if let Some(query) = query {
426            //  serde_urlencoded turns the params struct into pairs, but writes
427            //  them with form-encoding rules. Re-encode strictly so the five
428            //  SDKs put the same bytes on the wire.
429            let form =
430                serde_urlencoded::to_string(query).map_err(|err| Error::Encode(err.to_string()))?;
431            let encoded = form_urlencoded::parse(form.as_bytes())
432                .map(|(name, value)| {
433                    format!(
434                        "{}={}",
435                        encode_query_component(name.as_ref()),
436                        encode_query_component(value.as_ref())
437                    )
438                })
439                .collect::<Vec<_>>()
440                .join("&");
441            if !encoded.is_empty() {
442                url.set_query(Some(&encoded));
443            }
444        }
445        for (name, value) in &self.options.extra_query {
446            let pair = format!(
447                "{}={}",
448                encode_query_component(name),
449                encode_query_component(value)
450            );
451            let joined = match url.query() {
452                Some(existing) if !existing.is_empty() => format!("{existing}&{pair}"),
453                _ => pair,
454            };
455            url.set_query(Some(&joined));
456        }
457        Ok(url)
458    }
459}
460
461/// Fluent configuration for [`Client`].
462#[derive(Debug)]
463pub struct ClientBuilder {
464    api_key: Option<String>,
465    base_url: String,
466    timeout: Duration,
467    max_retries: u32,
468    user_agent: Option<String>,
469    default_headers: HeaderMap,
470    http: Option<reqwest::Client>,
471    sse_token_in_query: bool,
472}
473
474impl Default for ClientBuilder {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480impl ClientBuilder {
481    pub fn new() -> Self {
482        Self {
483            api_key: None,
484            base_url: DEFAULT_BASE_URL.to_string(),
485            timeout: Duration::from_secs(60),
486            max_retries: 2,
487            user_agent: None,
488            default_headers: HeaderMap::new(),
489            http: None,
490            sse_token_in_query: false,
491        }
492    }
493
494    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
495        self.api_key = Some(api_key.into());
496        self
497    }
498
499    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
500        self.base_url = base_url.into();
501        self
502    }
503
504    /// Per-request timeout. Default 60 s.
505    pub fn timeout(mut self, timeout: Duration) -> Self {
506        self.timeout = timeout;
507        self
508    }
509
510    /// Retries for transient failures. Default 2.
511    pub fn max_retries(mut self, max_retries: u32) -> Self {
512        self.max_retries = max_retries;
513        self
514    }
515
516    /// Appended to the SDK's own User-Agent.
517    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
518        self.user_agent = Some(user_agent.into());
519        self
520    }
521
522    pub fn default_header(mut self, name: &str, value: &str) -> Result<Self> {
523        let name = HeaderName::from_bytes(name.as_bytes())
524            .map_err(|err| Error::Config(format!("invalid header name: {err}")))?;
525        let value = HeaderValue::from_str(value)
526            .map_err(|err| Error::Config(format!("invalid header value: {err}")))?;
527        self.default_headers.insert(name, value);
528        Ok(self)
529    }
530
531    /// Supply a preconfigured `reqwest::Client` (proxies, custom TLS, tracing).
532    pub fn http_client(mut self, http: reqwest::Client) -> Self {
533        self.http = Some(http);
534        self
535    }
536
537    /// Send the API key as `?token=` on SSE requests instead of a header.
538    pub fn sse_token_in_query(mut self, enabled: bool) -> Self {
539        self.sse_token_in_query = enabled;
540        self
541    }
542
543    pub fn build(self) -> Result<Client> {
544        let api_key = self.api_key.ok_or_else(|| {
545            Error::Config("missing API key: call .api_key(...) or Client::from_env()".into())
546        })?;
547        // A trailing slash makes `Url::join` keep the whole base path.
548        let mut base = self.base_url.trim_end_matches('/').to_string();
549        base.push('/');
550        let base_url =
551            Url::parse(&base).map_err(|err| Error::Config(format!("invalid base URL: {err}")))?;
552
553        let sdk_agent = format!("uarp-sdk-rust/{}", env!("CARGO_PKG_VERSION"));
554        let user_agent = match self.user_agent {
555            Some(extra) => format!("{sdk_agent} {extra}"),
556            None => sdk_agent,
557        };
558
559        let http = match self.http {
560            Some(http) => http,
561            None => reqwest::Client::builder()
562                .build()
563                .map_err(|err| Error::Config(format!("could not build HTTP client: {err}")))?,
564        };
565
566        Ok(Client {
567            options: RequestOptions::default(),
568            inner: Arc::new(Inner {
569                http,
570                base_url,
571                api_key,
572                max_retries: self.max_retries,
573                timeout: self.timeout,
574                user_agent,
575                default_headers: self.default_headers,
576                sse_token_in_query: self.sse_token_in_query,
577            }),
578        })
579    }
580}
581
582// ------------------------------------------------------------------ helpers
583
584async fn decode_json<R: DeserializeOwned>(response: reqwest::Response) -> Result<R> {
585    let bytes = response.bytes().await.map_err(Error::Connection)?;
586    // Endpoints documented without a response body still deserialize as `null`.
587    let slice: &[u8] = if bytes.is_empty() { b"null" } else { &bytes };
588    serde_json::from_slice(slice).map_err(Error::Decode)
589}
590
591/// RFC 9457 keys. A body carrying none of them is not a problem document,
592/// however well-formed its JSON is.
593const PROBLEM_KEYS: [&str; 6] = ["type", "title", "status", "detail", "correlationId", "errors"];
594
595/// Extract the failure message, whatever shape the server used to send it.
596///
597/// Every field of `Problem` is `Option` with `#[serde(default)]` and nothing
598/// denies unknown keys, so `{"error": "Insufficient role"}` deserialized
599/// SUCCESSFULLY into an all-`None` `Problem` — and the `unwrap_or_else` branch
600/// that preserves the raw body never ran, because it only fires on a decode
601/// error. It was dead code for exactly the input it was written for. The API
602/// answers 32 places with that bare shape.
603///
604/// Diagnosed by the iOS session against the Swift client; the same hole exists
605/// in TypeScript, Kotlin and here.
606pub(crate) fn problem_from_slice(bytes: &[u8]) -> Problem {
607    let raw = || String::from_utf8_lossy(bytes).into_owned();
608    let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
609        return Problem { detail: Some(raw()), ..Problem::default() };
610    };
611    let Some(object) = value.as_object() else {
612        return Problem { detail: Some(raw()), ..Problem::default() };
613    };
614    if PROBLEM_KEYS.iter().any(|k| object.contains_key(*k)) {
615        if let Ok(problem) = serde_json::from_slice::<Problem>(bytes) {
616            return problem;
617        }
618    }
619    let message = object
620        .get("error")
621        .and_then(|e| {
622            e.as_str()
623                .map(str::to_owned)
624                .or_else(|| e.get("message").and_then(|m| m.as_str()).map(str::to_owned))
625        })
626        .or_else(|| object.get("message").and_then(|m| m.as_str()).map(str::to_owned));
627    Problem { detail: Some(message.unwrap_or_else(raw)), ..Problem::default() }
628}
629
630async fn read_problem(response: reqwest::Response) -> Problem {
631    match response.bytes().await {
632        Ok(bytes) if !bytes.is_empty() => problem_from_slice(&bytes),
633        _ => Problem::default(),
634    }
635}
636
637pub(crate) fn collect_headers(headers: &HeaderMap) -> HashMap<String, String> {
638    headers
639        .iter()
640        .filter_map(|(name, value)| {
641            Some((
642                name.as_str().to_ascii_lowercase(),
643                value.to_str().ok()?.to_string(),
644            ))
645        })
646        .collect()
647}
648
649fn parse_retry_after(headers: &HashMap<String, String>) -> Option<Duration> {
650    let raw = headers.get("retry-after")?;
651    raw.parse::<f64>()
652        .ok()
653        .filter(|seconds| seconds.is_finite() && *seconds >= 0.0)
654        .map(Duration::from_secs_f64)
655}
656
657/// Full-jitter exponential backoff capped at 8 s.
658pub(crate) fn backoff(attempt: u32) -> Duration {
659    let base = 500u64.saturating_mul(1u64 << attempt.min(4)).min(8_000);
660    // No RNG dependency: the low bits of a v4 UUID are already random.
661    let jitter = (uuid::Uuid::new_v4().as_u128() as u64) % (base / 2 + 1);
662    Duration::from_millis(base / 2 + jitter)
663}
664
665#[cfg(test)]
666mod problem_decoding_tests {
667    use super::problem_from_slice;
668
669    /// A failure the server did not phrase as RFC 9457 must still reach the
670    /// caller. 32 API handlers answer with a bare `{"error": "..."}`, and every
671    /// one of them used to deserialize into an all-`None` `Problem`.
672    #[test]
673    fn bare_error_key_keeps_its_message() {
674        let p = problem_from_slice(br#"{"error": "Insufficient role: owner required"}"#);
675        assert_eq!(p.detail.as_deref(), Some("Insufficient role: owner required"));
676    }
677
678    #[test]
679    fn nested_error_message_keeps_its_message() {
680        let p = problem_from_slice(br#"{"error": {"message": "Upstream error"}}"#);
681        assert_eq!(p.detail.as_deref(), Some("Upstream error"));
682    }
683
684    #[test]
685    fn real_problem_document_is_used_as_is() {
686        let p = problem_from_slice(
687            br#"{"type":"about:blank","title":"Not Found","status":404,"detail":"no such agent"}"#,
688        );
689        assert_eq!(p.title.as_deref(), Some("Not Found"));
690        assert_eq!(p.detail.as_deref(), Some("no such agent"));
691        assert_eq!(p.status, Some(404));
692    }
693
694    #[test]
695    fn non_json_body_is_not_thrown_away() {
696        let p = problem_from_slice(b"<html><body>502 Bad Gateway</body></html>");
697        assert!(p.detail.unwrap().contains("Bad Gateway"));
698    }
699}