alpaca-data 0.10.2

High-performance Rust client for Alpaca Market Data API
Documentation
use std::time::Duration;
use std::{
    sync::{Arc, Mutex},
    time::Duration as StdDuration,
};

use alpaca_data::{Client, Error, ObservedResponseMeta, TransportObserver, crypto};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn latest_quotes_body() -> &'static str {
    r#"{"quotes":{"BTC/USD":{"ap":67005.5,"as":1.26733,"bp":66894.8,"bs":2.56753,"t":"2026-04-04T00:00:04.184229364Z"}}}"#
}

#[derive(Default)]
struct RecordingObserver {
    seen: Mutex<Vec<ObservedResponseMeta>>,
}

impl TransportObserver for RecordingObserver {
    fn on_response(&self, meta: &ObservedResponseMeta) {
        self.seen
            .lock()
            .expect("observer buffer should be available")
            .push(meta.clone());
    }
}

#[tokio::test]
async fn rate_limit_maps_retry_after_header() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(
            ResponseTemplate::new(429)
                .insert_header("retry-after", "3")
                .insert_header("apca-request-id", "req-429")
                .set_body_string("too many requests"),
        )
        .mount(&server)
        .await;

    let error = Client::builder()
        .base_url(server.uri())
        .build()
        .expect("client should build")
        .crypto()
        .latest_quotes(crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(crypto::Loc::Us),
        })
        .await
        .expect_err("request should fail");

    assert!(matches!(
        error,
        Error::RateLimited {
            endpoint: "crypto.latest_quotes",
            retry_after: Some(3),
            request_id: Some(ref request_id),
            attempt_count: 0,
            ..
        } if request_id == "req-429"
    ));

    assert_eq!(error.endpoint(), Some("crypto.latest_quotes"));
    assert_eq!(error.request_id(), Some("req-429"));
}

#[tokio::test]
async fn malformed_json_maps_deserialize_error() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(ResponseTemplate::new(200).set_body_raw("not-json", "application/json"))
        .mount(&server)
        .await;

    let error = Client::builder()
        .base_url(server.uri())
        .build()
        .expect("client should build")
        .crypto()
        .latest_quotes(crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(crypto::Loc::Us),
        })
        .await
        .expect_err("request should fail");

    assert!(matches!(error, Error::Deserialize(_)));
}

#[tokio::test]
async fn retry_on_429_can_succeed_when_enabled() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(
            ResponseTemplate::new(429)
                .insert_header("retry-after", "0")
                .set_body_string("too many requests"),
        )
        .up_to_n_times(1)
        .expect(1)
        .mount(&server)
        .await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(
            ResponseTemplate::new(200).set_body_raw(latest_quotes_body(), "application/json"),
        )
        .expect(1)
        .mount(&server)
        .await;

    let response = Client::builder()
        .base_url(server.uri())
        .max_retries(1)
        .retry_on_429(true)
        .respect_retry_after(true)
        .base_backoff(Duration::from_millis(1))
        .max_backoff(Duration::from_millis(1))
        .build()
        .expect("client should build")
        .crypto()
        .latest_quotes(crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(crypto::Loc::Us),
        })
        .await
        .expect("request should succeed after retry");

    assert!(response.quotes.contains_key("BTC/USD"));
    assert_eq!(
        server
            .received_requests()
            .await
            .expect("requests should be recorded")
            .len(),
        2
    );
}

#[tokio::test]
async fn server_errors_retry_and_then_succeed_within_budget() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(ResponseTemplate::new(500).set_body_string("server error"))
        .up_to_n_times(1)
        .expect(1)
        .mount(&server)
        .await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(
            ResponseTemplate::new(200).set_body_raw(latest_quotes_body(), "application/json"),
        )
        .expect(1)
        .mount(&server)
        .await;

    let response = Client::builder()
        .base_url(server.uri())
        .max_retries(1)
        .base_backoff(Duration::from_millis(1))
        .max_backoff(Duration::from_millis(1))
        .build()
        .expect("client should build")
        .crypto()
        .latest_quotes(crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(crypto::Loc::Us),
        })
        .await
        .expect("request should succeed after retry");

    assert!(response.quotes.contains_key("BTC/USD"));
}

#[tokio::test]
async fn server_errors_return_terminal_status_after_retry_budget_is_exhausted() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(
            ResponseTemplate::new(500)
                .insert_header("apca-request-id", "req-500")
                .set_body_string("server error"),
        )
        .expect(2)
        .mount(&server)
        .await;

    let error = Client::builder()
        .base_url(server.uri())
        .max_retries(1)
        .base_backoff(Duration::from_millis(1))
        .max_backoff(Duration::from_millis(1))
        .build()
        .expect("client should build")
        .crypto()
        .latest_quotes(crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(crypto::Loc::Us),
        })
        .await
        .expect_err("request should fail after retry budget is exhausted");

    assert!(matches!(
        error,
        Error::HttpStatus {
            endpoint: "crypto.latest_quotes",
            status: 500,
            request_id: Some(ref request_id),
            attempt_count: 1,
            ..
        } if request_id == "req-500"
    ));
    assert_eq!(error.endpoint(), Some("crypto.latest_quotes"));
    assert_eq!(error.request_id(), Some("req-500"));
}

#[tokio::test]
async fn error_bodies_are_snippets_and_display_transport_metadata() {
    let server = MockServer::start().await;
    let long_body = "x".repeat(1024);

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(
            ResponseTemplate::new(500)
                .insert_header("apca-request-id", "req-long")
                .set_body_string(long_body.clone()),
        )
        .mount(&server)
        .await;

    let error = Client::builder()
        .base_url(server.uri())
        .build()
        .expect("client should build")
        .crypto()
        .latest_quotes(crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(crypto::Loc::Us),
        })
        .await
        .expect_err("request should fail");

    match &error {
        Error::HttpStatus {
            endpoint,
            request_id,
            body,
            ..
        } => {
            assert_eq!(*endpoint, "crypto.latest_quotes");
            assert_eq!(request_id.as_deref(), Some("req-long"));
            let body = body.as_deref().expect("body snippet should be preserved");
            assert!(body.len() < long_body.len());
            assert!(body.ends_with("..."));
        }
        other => panic!("expected HttpStatus error, got {other:?}"),
    }

    let display = error.to_string();
    assert!(display.contains("crypto.latest_quotes"));
    assert!(display.contains("req-long"));
}

#[tokio::test]
async fn observer_receives_success_metadata() {
    let server = MockServer::start().await;
    let observer = Arc::new(RecordingObserver::default());

    Mock::given(method("GET"))
        .and(path("/v1beta3/crypto/us/latest/quotes"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("apca-request-id", "req-success")
                .set_body_raw(latest_quotes_body(), "application/json"),
        )
        .mount(&server)
        .await;

    let response = Client::builder()
        .base_url(server.uri())
        .observer(observer.clone())
        .build()
        .expect("client should build")
        .crypto()
        .latest_quotes(crypto::LatestQuotesRequest {
            symbols: vec!["BTC/USD".into()],
            loc: Some(crypto::Loc::Us),
        })
        .await
        .expect("request should succeed");

    assert!(response.quotes.contains_key("BTC/USD"));

    let seen = observer
        .seen
        .lock()
        .expect("observer buffer should be available");
    assert_eq!(seen.len(), 1);
    assert_eq!(seen[0].endpoint_name, "crypto.latest_quotes");
    assert_eq!(seen[0].status, 200);
    assert_eq!(seen[0].request_id.as_deref(), Some("req-success"));
    assert_eq!(seen[0].attempt_count, 0);
    assert!(seen[0].url.ends_with("/v1beta3/crypto/us/latest/quotes"));
    assert!(seen[0].elapsed >= StdDuration::ZERO);
}