Skip to main content

alpaca_sdk/
rest.rs

1//! The shared HTTP transport.
2//!
3//! Every REST client in this crate wraps a [`RestClient`]. It is public so the
4//! raw request methods stay available as an escape hatch for routes this crate
5//! does not wrap.
6//!
7//! [`RestClient::request_raw`] returns the body undecoded, which is the escape
8//! hatch for a route this crate has not wrapped or a response whose shape has
9//! changed.
10
11use std::time::Duration;
12
13use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
14use reqwest::{Method, RequestBuilder};
15use serde::Serialize;
16use serde::de::DeserializeOwned;
17
18use crate::auth::Credentials;
19use crate::config::{BaseUrl, RetryConfig, user_agent};
20use crate::error::{ApiError, Error, Result};
21
22/// Response bodies longer than this are truncated in [`Error::Decode`].
23const MAX_ERROR_BODY: usize = 2048;
24
25/// Reads `Retry-After`, in the delta-seconds form only.
26///
27/// A 429 that carries this header is stating the exact answer the backoff curve
28/// is guessing at, so it wins when present.
29///
30/// RFC 9110 also allows an HTTP-date, which this deliberately does not read:
31/// honoring one means trusting that the caller's clock agrees with the server's,
32/// and a date honored badly waits either far too long or not at all. Anything
33/// that is not a plain count of seconds is treated as absent, which falls back
34/// to the curve — the behaviour before this header was read at all.
35pub(crate) fn retry_after(headers: &HeaderMap) -> Option<Duration> {
36    headers
37        .get(reqwest::header::RETRY_AFTER)?
38        .to_str()
39        .ok()?
40        .trim()
41        .parse::<u64>()
42        .ok()
43        .map(Duration::from_secs)
44}
45
46/// Whether replaying this request could do its work a second time.
47///
48/// The retry policy is a set of status codes, and a status code alone cannot
49/// answer this. A 504 means the gateway gave up waiting for the *answer*, not
50/// that nothing happened: Alpaca may well have accepted the order. Replaying it
51/// three times on the default policy is four orders, and the caller is handed
52/// [`Error::RetriesExhausted`] — so they believe zero were placed.
53///
54/// A 429 is the exception, and the reason this takes the status as well as the
55/// method. It is refused by the rate limiter before it reaches anything that
56/// acts on it, so nothing was done and replaying is safe whatever the method.
57///
58/// Everything else defers to HTTP's own idempotency definition: `GET`, `HEAD`,
59/// `PUT`, `DELETE`, `OPTIONS` and `TRACE` may be replayed; `POST` and `PATCH`
60/// may not. Alpaca's money-moving routes — `POST /v2/orders`,
61/// `POST /v1/journals`, every transfer — are all `POST`.
62///
63/// Alpaca's reference names the mechanism that would make a `POST` replayable:
64/// *"Always supply `Idempotency-Key` when creating journals"*. Until this crate
65/// sends one, not replaying is the only safe answer.
66fn replay_is_safe(method: &Method, replay: Replay, status: u16) -> bool {
67    if status == 429 {
68        return true;
69    }
70    replay == Replay::ByMethod && method.is_idempotent()
71}
72
73/// Whether a route may be replayed on the strength of its HTTP method.
74///
75/// Almost all of them may: the method is what HTTP defines idempotency by, and
76/// for `GET`, `PUT` and `DELETE` that definition holds here too.
77///
78/// The exception is a `DELETE` that is not idempotent in *effect*.
79/// `DELETE /v2/positions/{asset}` and `DELETE /v2/positions` do not delete a
80/// record — they submit liquidating market orders. If Alpaca accepts the order
81/// and the gateway then drops the response, the position is still open when the
82/// replay arrives, so the replay submits a second full-size liquidation and
83/// sells the caller into a short. That is the same failure `POST` is excluded
84/// for, wearing a different verb.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum Replay {
88    /// HTTP's answer is the right one for this route.
89    ByMethod,
90    /// This route acts, whatever its verb says. Never replay it.
91    Never,
92}
93
94/// A request with no query parameters or body.
95#[derive(Debug, Clone, Copy, Serialize)]
96pub struct Empty;
97
98/// Configuration shared by every REST client.
99///
100/// Build one with [`RestConfig::new`] and adjust it with the builder methods.
101/// It is `#[non_exhaustive]` so that a new knob can arrive as a field rather
102/// than as a breaking change.
103#[derive(Debug, Clone)]
104#[non_exhaustive]
105pub struct RestConfig {
106    /// The endpoint to target. Overridable for proxies and mock servers.
107    pub base_url: String,
108    /// The path segment inserted between the base URL and the request path.
109    pub api_version: String,
110    /// How retryable failures are handled.
111    pub retry: RetryConfig,
112    /// Per-request timeout, for REST requests only.
113    ///
114    /// `None` by default, and deliberately: Alpaca publishes no latency
115    /// guarantee to pick a number from, and a timeout short enough to be useful
116    /// on a quiet endpoint will fire on a slow one. Set it per client if the
117    /// caller has a deadline of their own.
118    ///
119    /// It does **not** reach the event streams. This is a total deadline on a
120    /// whole request, and an event stream's response body is never finished, so
121    /// applying it there would cap the life of every subscription rather than
122    /// bound a slow call.
123    pub timeout: Option<Duration>,
124}
125
126impl RestConfig {
127    /// A configuration targeting `base_url` at API version `v2`.
128    pub fn new(base_url: impl Into<String>) -> Self {
129        Self {
130            base_url: base_url.into(),
131            api_version: "v2".to_owned(),
132            retry: RetryConfig::default(),
133            timeout: None,
134        }
135    }
136
137    /// Overrides the API version segment.
138    #[must_use]
139    pub fn api_version(mut self, version: impl Into<String>) -> Self {
140        self.api_version = version.into();
141        self
142    }
143
144    /// Overrides the retry policy.
145    #[must_use]
146    pub fn retry(mut self, retry: RetryConfig) -> Self {
147        self.retry = retry;
148        self
149    }
150
151    /// Sets a per-request timeout.
152    #[must_use]
153    pub fn timeout(mut self, timeout: Duration) -> Self {
154        self.timeout = Some(timeout);
155        self
156    }
157}
158
159impl From<BaseUrl> for RestConfig {
160    fn from(url: BaseUrl) -> Self {
161        Self::new(url.as_str())
162    }
163}
164
165/// An authenticated HTTP client for one Alpaca endpoint.
166#[derive(Debug, Clone)]
167pub struct RestClient {
168    http: reqwest::Client,
169    config: RestConfig,
170}
171
172impl RestClient {
173    /// Builds a client from credentials and configuration.
174    ///
175    /// Redirects are disabled deliberately. Because the base URL is overridable,
176    /// an `http://` endpoint that redirects to `https://` would replay a POST body
177    /// — and its auth headers — over cleartext, so the request must fail loudly
178    /// instead. The one route that legitimately redirects, the broker document
179    /// download, uses its own client.
180    ///
181    /// # Errors
182    /// Returns [`Error::Credentials`] if the credentials cannot be encoded as
183    /// headers, or [`Error::Transport`] if the underlying HTTP client fails to build.
184    pub fn new(credentials: &Credentials, config: RestConfig) -> Result<Self> {
185        Self::build(Some(credentials), config)
186    }
187
188    /// Builds a client that sends no authentication headers.
189    ///
190    /// Alpaca's crypto market data endpoints serve unauthenticated requests.
191    /// Every other endpoint requires credentials.
192    ///
193    /// # Errors
194    /// Returns [`Error::Transport`] if the underlying HTTP client fails to build.
195    pub fn unauthenticated(config: RestConfig) -> Result<Self> {
196        Self::build(None, config)
197    }
198
199    fn build(credentials: Option<&Credentials>, config: RestConfig) -> Result<Self> {
200        let mut headers = HeaderMap::new();
201        if let Some(credentials) = credentials {
202            credentials.apply(&mut headers)?;
203        }
204        headers.insert(USER_AGENT, HeaderValue::from_static(user_agent()));
205        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
206
207        let mut builder = reqwest::Client::builder()
208            .default_headers(headers)
209            .redirect(reqwest::redirect::Policy::none());
210
211        if let Some(timeout) = config.timeout {
212            builder = builder.timeout(timeout);
213        }
214
215        Ok(Self {
216            http: builder.build().map_err(Error::transport)?,
217            config,
218        })
219    }
220
221    /// The configuration this client was built with.
222    #[must_use]
223    pub fn config(&self) -> &RestConfig {
224        &self.config
225    }
226
227    /// The same client, targeting a different API version segment.
228    ///
229    /// Alpaca versions routes individually rather than per API, and it is not
230    /// unusual for a client's own version to be wrong for a given route: the
231    /// trading API is `v2` but its locate routes are `v1` and its per-market
232    /// calendar is `v3`; the broker API is `v1` but funding wallets are
233    /// `v1beta` and logos `v1beta1`.
234    ///
235    /// Writing that at the call site is deliberate. A version buried in a
236    /// client constructor is the mistake that shipped three event streams
237    /// pointing at routes Alpaca had retired; this way the version sits next to
238    /// the path it belongs to.
239    ///
240    /// Cheap: the underlying HTTP client is shared, not rebuilt.
241    #[must_use]
242    pub fn at_version(&self, api_version: &str) -> Self {
243        Self {
244            http: self.http.clone(),
245            config: RestConfig {
246                api_version: api_version.to_owned(),
247                ..self.config.clone()
248            },
249        }
250    }
251
252    /// Issues a `GET`, sending `query` as URL parameters.
253    ///
254    /// # Errors
255    /// Propagates transport, API, and decoding failures.
256    pub async fn get<T, Q>(&self, path: &str, query: &Q) -> Result<T>
257    where
258        T: DeserializeOwned,
259        Q: Serialize + ?Sized,
260    {
261        self.decode(
262            path,
263            self.send(
264                Method::GET,
265                Replay::ByMethod,
266                path,
267                Some(query),
268                None::<&Empty>,
269            )
270            .await?,
271        )
272    }
273
274    /// Issues a `DELETE`, sending `query` as URL parameters.
275    ///
276    /// `DELETE` parameters go in the query string, not the body — which is what
277    /// the endpoints taking them (`cancel_orders`, `close_position`) expect.
278    ///
279    /// # Errors
280    /// Propagates transport, API, and decoding failures.
281    pub async fn delete<T, Q>(&self, path: &str, query: &Q) -> Result<T>
282    where
283        T: DeserializeOwned,
284        Q: Serialize + ?Sized,
285    {
286        self.decode(
287            path,
288            self.send(
289                Method::DELETE,
290                Replay::ByMethod,
291                path,
292                Some(query),
293                None::<&Empty>,
294            )
295            .await?,
296        )
297    }
298
299    /// Issues a `DELETE` that must never be replayed.
300    ///
301    /// For the routes whose verb says "remove a record" and whose effect is
302    /// "submit an order": `DELETE /v2/positions/{asset}` and
303    /// `DELETE /v2/positions`, plus their broker twins. HTTP calls a `DELETE`
304    /// idempotent and for those four it is not — a replayed liquidation sells
305    /// the same quantity twice. A timeout on one is reported, not retried; a 429
306    /// still is, because the rate limiter refused it before anything acted.
307    ///
308    /// # Errors
309    /// Propagates transport, API, and decoding failures.
310    pub async fn delete_effectful<T, Q>(&self, path: &str, query: &Q) -> Result<T>
311    where
312        T: DeserializeOwned,
313        Q: Serialize + ?Sized,
314    {
315        self.decode(
316            path,
317            self.send(
318                Method::DELETE,
319                Replay::Never,
320                path,
321                Some(query),
322                None::<&Empty>,
323            )
324            .await?,
325        )
326    }
327
328    /// Issues a `POST` with a JSON body.
329    ///
330    /// # Errors
331    /// Propagates transport, API, and decoding failures.
332    pub async fn post<T, B>(&self, path: &str, body: &B) -> Result<T>
333    where
334        T: DeserializeOwned,
335        B: Serialize + ?Sized,
336    {
337        self.decode(
338            path,
339            self.send(
340                Method::POST,
341                Replay::ByMethod,
342                path,
343                None::<&Empty>,
344                Some(body),
345            )
346            .await?,
347        )
348    }
349
350    /// Issues a `PUT` with a JSON body.
351    ///
352    /// # Errors
353    /// Propagates transport, API, and decoding failures.
354    pub async fn put<T, B>(&self, path: &str, body: &B) -> Result<T>
355    where
356        T: DeserializeOwned,
357        B: Serialize + ?Sized,
358    {
359        self.decode(
360            path,
361            self.send(
362                Method::PUT,
363                Replay::ByMethod,
364                path,
365                None::<&Empty>,
366                Some(body),
367            )
368            .await?,
369        )
370    }
371
372    /// Issues a `PATCH` with a JSON body.
373    ///
374    /// # Errors
375    /// Propagates transport, API, and decoding failures.
376    pub async fn patch<T, B>(&self, path: &str, body: &B) -> Result<T>
377    where
378        T: DeserializeOwned,
379        B: Serialize + ?Sized,
380    {
381        self.decode(
382            path,
383            self.send(
384                Method::PATCH,
385                Replay::ByMethod,
386                path,
387                None::<&Empty>,
388                Some(body),
389            )
390            .await?,
391        )
392    }
393
394    /// Issues a request with both a query string and a body.
395    ///
396    /// `replay` decides what happens on a retryable status. [`Replay::ByMethod`]
397    /// is right for almost everything; pass [`Replay::Never`] for a route whose
398    /// effect contradicts its verb — one that submits an order or moves money
399    /// behind a `PUT` or a `DELETE`.
400    ///
401    /// The six methods above cover routes that take one or the other, which is
402    /// nearly all of them. A handful take both — `PUT /v2/watchlists:by_name`
403    /// names the watchlist in the query and carries the update in the body —
404    /// and folding the query into the path string would skip its encoding.
405    ///
406    /// # Errors
407    /// Propagates transport, API, and decoding failures.
408    pub async fn request<T, Q, B>(
409        &self,
410        method: Method,
411        replay: Replay,
412        path: &str,
413        query: Option<&Q>,
414        body: Option<&B>,
415    ) -> Result<T>
416    where
417        T: DeserializeOwned,
418        Q: Serialize + ?Sized,
419        B: Serialize + ?Sized,
420    {
421        self.decode(path, self.send(method, replay, path, query, body).await?)
422    }
423
424    /// Issues a `GET` and returns the response body as bytes.
425    ///
426    /// For the routes that answer with something other than JSON. The logo
427    /// endpoint is the only one in this crate: it serves `image/png`, and
428    /// putting it through [`RestClient::get`] would try to parse a PNG as JSON.
429    ///
430    /// # Errors
431    /// Propagates transport and API failures.
432    pub async fn get_bytes<Q>(&self, path: &str, query: &Q) -> Result<Vec<u8>>
433    where
434        Q: Serialize + ?Sized,
435    {
436        let request = self.http.get(self.url(path)).query(query);
437        self.execute_bytes(&Method::GET, Replay::ByMethod, request, path)
438            .await
439    }
440
441    /// Issues a request and returns the raw response body without deserializing.
442    ///
443    /// This is the escape hatch for a route this crate does not wrap, which
444    /// makes `replay` load-bearing: the wrapped routes have their answer chosen
445    /// for them, and here it is yours. [`Replay::Never`] for anything that acts
446    /// — a replayed liquidation sells twice.
447    ///
448    /// # Errors
449    /// Propagates transport and API failures.
450    pub async fn request_raw<Q, B>(
451        &self,
452        method: Method,
453        replay: Replay,
454        path: &str,
455        query: Option<&Q>,
456        body: Option<&B>,
457    ) -> Result<String>
458    where
459        Q: Serialize + ?Sized,
460        B: Serialize + ?Sized,
461    {
462        self.send(method, replay, path, query, body).await
463    }
464
465    /// Builds the absolute URL for `path`.
466    ///
467    /// Plain concatenation of `base_url`, the API version segment, and the path.
468    /// Not `Url::join`, which would treat a path as relative and drop segments.
469    fn url(&self, path: &str) -> String {
470        format!(
471            "{}/{}{path}",
472            self.config.base_url.trim_end_matches('/'),
473            self.config.api_version
474        )
475    }
476
477    /// Runs the request, retrying retryable statuses, and returns the body text.
478    async fn send<Q, B>(
479        &self,
480        method: Method,
481        replay: Replay,
482        path: &str,
483        query: Option<&Q>,
484        body: Option<&B>,
485    ) -> Result<String>
486    where
487        Q: Serialize + ?Sized,
488        B: Serialize + ?Sized,
489    {
490        let mut request = self.http.request(method.clone(), self.url(path));
491        if let Some(query) = query {
492            request = request.query(query);
493        }
494        if let Some(body) = body {
495            request = request.json(body);
496        }
497        self.execute(&method, replay, request, path).await
498    }
499
500    /// Runs the retry loop and reads the successful body as text.
501    async fn execute(
502        &self,
503        method: &Method,
504        replay: Replay,
505        request: RequestBuilder,
506        path: &str,
507    ) -> Result<String> {
508        self.execute_response(method, replay, request, path)
509            .await?
510            .text()
511            .await
512            .map_err(Error::transport)
513    }
514
515    /// Runs the retry loop and reads the successful body as bytes.
516    async fn execute_bytes(
517        &self,
518        method: &Method,
519        replay: Replay,
520        request: RequestBuilder,
521        path: &str,
522    ) -> Result<Vec<u8>> {
523        Ok(self
524            .execute_response(method, replay, request, path)
525            .await?
526            .bytes()
527            .await
528            .map_err(Error::transport)?
529            .to_vec())
530    }
531
532    /// The retry loop. Deliberately non-generic so it is compiled once rather
533    /// than per request type.
534    async fn execute_response(
535        &self,
536        method: &Method,
537        replay: Replay,
538        request: RequestBuilder,
539        path: &str,
540    ) -> Result<reqwest::Response> {
541        let retry = &self.config.retry;
542        // `attempts` counts retries *after* the first request, so a value of 3
543        // means up to 4 requests in total. Saturating because `attempts` is a
544        // caller-supplied `u32`: `u32::MAX` would overflow, panicking in debug
545        // and wrapping to a zero-iteration loop in release.
546        let total_attempts = retry.attempts.saturating_add(1);
547
548        // The original builder is sent first and a copy is kept for the next
549        // attempt. Cloning up front instead would swallow a builder-level error
550        // — a query string that failed to serialize, say — behind a `None` from
551        // `try_clone`, reporting it as an unretryable body.
552        let mut current = request;
553
554        for attempt in 1..=total_attempts {
555            let next = if attempt < total_attempts {
556                current.try_clone()
557            } else {
558                None
559            };
560
561            let response = current.send().await.map_err(Error::transport)?;
562            let status = response.status().as_u16();
563
564            if response.status().is_success() {
565                return Ok(response);
566            }
567
568            // Read before the body: `text()` consumes the response, headers
569            // and all.
570            let retry_after = retry_after(response.headers());
571
572            let body = response.text().await.unwrap_or_default();
573            let api_error = ApiError::from_body(status, path, body);
574
575            let last_attempt = attempt == total_attempts;
576            if !retry.should_retry(status) || !replay_is_safe(method, replay, status) {
577                return Err(Error::Api(api_error));
578            }
579            if last_attempt {
580                return Err(Error::RetriesExhausted {
581                    attempts: total_attempts,
582                    last: api_error,
583                });
584            }
585
586            let Some(next) = next else {
587                // Only reachable with a streaming body, which this crate never
588                // sends; keep the retry loop honest rather than silently
589                // returning the last error as if it were final.
590                return Err(Error::InvalidRequest(
591                    "request cannot be retried because its body is a stream".to_owned(),
592                ));
593            };
594            current = next;
595
596            // `attempt` is also the number of consecutive failures so far, which
597            // is what the backoff curve is indexed by. A `Retry-After` overrides
598            // it — but clamped, because the value comes from the other end.
599            let delay = retry_after.map_or_else(
600                || retry.delay(attempt),
601                |after| after.min(retry.retry_after_cap()),
602            );
603            tracing::debug!(
604                path,
605                status,
606                attempt,
607                total_attempts,
608                delay_ms = delay.as_millis(),
609                honored_retry_after = retry_after.is_some(),
610                "retryable response, backing off"
611            );
612            tokio::time::sleep(delay).await;
613        }
614
615        // `total_attempts` is at least 1, so the loop always returns.
616        unreachable!("retry loop exited without returning")
617    }
618
619    /// Deserializes a body, treating an empty body as `null`.
620    ///
621    /// Alpaca answers several endpoints (notably `DELETE`) with `204 No Content`.
622    /// `T` is typically `()` or an `Option` there, both of which deserialize
623    /// from `null`.
624    fn decode<T: DeserializeOwned>(&self, path: &str, body: String) -> Result<T> {
625        let source = if body.trim().is_empty() {
626            "null"
627        } else {
628            &body
629        };
630
631        serde_json::from_str(source).map_err(|source| Error::Decode {
632            path: path.to_owned(),
633            body: truncate(&body),
634            source,
635        })
636    }
637}
638
639pub(crate) fn truncate(body: &str) -> String {
640    if body.len() <= MAX_ERROR_BODY {
641        return body.to_owned();
642    }
643    let mut end = MAX_ERROR_BODY;
644    while !body.is_char_boundary(end) {
645        end -= 1;
646    }
647    format!("{}… ({} bytes total)", &body[..end], body.len())
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn client(base_url: &str) -> RestClient {
655        let creds = Credentials::new("key", "secret").unwrap();
656        RestClient::new(&creds, RestConfig::new(base_url)).unwrap()
657    }
658
659    fn with_retry_after(value: &str) -> HeaderMap {
660        let mut headers = HeaderMap::new();
661        headers.insert(
662            reqwest::header::RETRY_AFTER,
663            HeaderValue::from_str(value).unwrap(),
664        );
665        headers
666    }
667
668    #[test]
669    fn retry_after_reads_delta_seconds() {
670        assert_eq!(
671            retry_after(&with_retry_after("120")),
672            Some(Duration::from_secs(120))
673        );
674        assert_eq!(
675            retry_after(&with_retry_after(" 3 ")),
676            Some(Duration::from_secs(3))
677        );
678    }
679
680    #[test]
681    fn no_retry_after_header_means_the_curve_applies() {
682        assert_eq!(retry_after(&HeaderMap::new()), None);
683    }
684
685    /// RFC 9110 allows this form and this crate does not read it: honouring a
686    /// date means trusting that the two clocks agree. Reading as absent puts the
687    /// caller back on the backoff curve, which is the safe direction.
688    #[test]
689    fn the_http_date_form_reads_as_absent() {
690        assert_eq!(
691            retry_after(&with_retry_after("Fri, 31 Dec 1999 23:59:59 GMT")),
692            None
693        );
694    }
695
696    #[test]
697    fn a_value_that_is_not_a_count_of_seconds_reads_as_absent() {
698        assert_eq!(retry_after(&with_retry_after("soon")), None);
699        // Negative and fractional values are not the delta-seconds form either.
700        assert_eq!(retry_after(&with_retry_after("-5")), None);
701        assert_eq!(retry_after(&with_retry_after("1.5")), None);
702    }
703
704    #[test]
705    fn url_joins_base_version_and_path() {
706        let client = client("https://paper-api.alpaca.markets");
707        assert_eq!(
708            client.url("/orders"),
709            "https://paper-api.alpaca.markets/v2/orders"
710        );
711    }
712
713    #[test]
714    fn trailing_slash_on_base_url_does_not_double_up() {
715        let client = client("https://paper-api.alpaca.markets/");
716        assert_eq!(
717            client.url("/orders"),
718            "https://paper-api.alpaca.markets/v2/orders"
719        );
720    }
721
722    #[test]
723    fn api_version_is_overridable() {
724        let creds = Credentials::new("key", "secret").unwrap();
725        let client = RestClient::new(
726            &creds,
727            RestConfig::new("https://broker-api.sandbox.alpaca.markets").api_version("v1"),
728        )
729        .unwrap();
730
731        assert_eq!(
732            client.url("/accounts"),
733            "https://broker-api.sandbox.alpaca.markets/v1/accounts"
734        );
735    }
736
737    #[test]
738    fn empty_body_decodes_as_unit() {
739        let client = client("https://paper-api.alpaca.markets");
740        client.decode::<()>("/orders/1", String::new()).unwrap();
741        client.decode::<()>("/orders/1", "   ".to_owned()).unwrap();
742    }
743
744    #[test]
745    fn empty_body_decodes_as_none() {
746        let client = client("https://paper-api.alpaca.markets");
747        let decoded: Option<u8> = client.decode("/orders/1", String::new()).unwrap();
748        assert_eq!(decoded, None);
749    }
750
751    #[test]
752    fn decode_failure_carries_the_body() {
753        let client = client("https://paper-api.alpaca.markets");
754        let err = client
755            .decode::<u8>("/account", "not json".to_owned())
756            .unwrap_err();
757
758        match err {
759            Error::Decode { path, body, .. } => {
760                assert_eq!(path, "/account");
761                assert_eq!(body, "not json");
762            }
763            other => panic!("expected Decode, got {other:?}"),
764        }
765    }
766
767    #[test]
768    fn long_bodies_are_truncated_on_a_char_boundary() {
769        // "é" is 2 bytes, so with it MAX_ERROR_BODY (2048) already lands on a
770        // boundary and the walk-back loop never runs — a version of `truncate`
771        // that dropped the loop entirely would still pass. "€" is 3 bytes, so
772        // 2048 falls mid-character, which is the case that actually exercises
773        // it: slicing at a non-boundary index panics outright, so this would
774        // fail loudly rather than quietly if the loop were broken.
775        let body = "€".repeat(1000);
776        assert!(
777            !body.is_char_boundary(MAX_ERROR_BODY),
778            "the test needs a cutoff that is NOT already on a boundary"
779        );
780
781        let truncated = truncate(&body);
782        assert!(truncated.ends_with("bytes total)"));
783        assert!(truncated.len() < body.len());
784    }
785
786    /// `RetryConfig::attempts` is public and takes a `u32`, so a caller can pass
787    /// `u32::MAX`. The loop bound used to be `attempts + 1`, which overflowed:
788    /// a panic in debug, and in release a `total_attempts` of 0, which skips the
789    /// loop entirely and falls through to the `unreachable!` below it. Both
790    /// failures are on the caller's side of an ordinary builder call, so the
791    /// bound saturates instead.
792    ///
793    /// The request has to actually be issued for this to prove anything —
794    /// computing the bound in the test would only re-test the arithmetic.
795    #[tokio::test]
796    async fn a_saturating_attempt_count_still_sends_one_request() {
797        use wiremock::matchers::{method as m, path as p};
798        use wiremock::{Mock, MockServer, ResponseTemplate};
799
800        let server = MockServer::start().await;
801        Mock::given(m("GET"))
802            .and(p("/v2/account"))
803            .respond_with(ResponseTemplate::new(200).set_body_string("7"))
804            .expect(1)
805            .mount(&server)
806            .await;
807
808        let creds = Credentials::new("key", "secret").unwrap();
809        let client = RestClient::new(
810            &creds,
811            RestConfig::new(server.uri()).retry(RetryConfig::default().attempts(u32::MAX)),
812        )
813        .unwrap();
814
815        // Succeeds on the first attempt, so the retry budget is never spent; the
816        // point is that establishing that budget no longer overflows.
817        let got: u8 = client.get("/account", &Empty).await.unwrap();
818        assert_eq!(got, 7);
819    }
820}