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}