spacetraders-client 0.1.0

Async Rust client for the SpaceTraders API v2 with priority-aware rate limiting and retry
Documentation
use std::time::Duration;

use thiserror::Error;

/// Whether an endpoint may be safely replayed after a transient failure.
///
/// Reads are idempotent — replaying a GET cannot corrupt server state — so any
/// transient error (429 / 5xx / communication) is retried automatically.
/// Mutations are not: after an ambiguous 5xx or a dropped connection the server
/// may already have applied the change, so replaying the POST risks
/// double-applying it. (A `sell_cargo` retried after a 500 that had in fact sold
/// the goods is exactly how a ship ends up with negative cargo.)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RetryPolicy {
    /// Safe/idempotent reads (GET): retry every transient failure.
    Idempotent,
    /// Non-idempotent mutations (POST/PATCH): only retry a 429, which the rate
    /// limiter rejects *before* the action runs; never replay on an ambiguous
    /// 5xx or communication error.
    Mutating,
}

/// A typed failure from an [`StClient`](crate::StClient) call: transport errors,
/// `401`/`429`/`5xx` HTTP classes, deserialization failures, and other game/API
/// errors. Use [`ClientError::is_retryable`] / [`ClientError::is_unauthorized`]
/// to branch on transient vs. terminal outcomes.
///
/// ```
/// use spacetraders_client::ClientError;
///
/// let err = ClientError::RateLimited { retry_after: None };
/// assert!(err.is_retryable());
/// assert!(!err.is_unauthorized());
/// ```
#[derive(Debug, Error)]
pub enum ClientError {
    /// Transport/communication failure (connection reset, timeout, DNS, ...).
    /// Transient: the engine should back off and retry rather than crash.
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),

    /// 401: the agent token is no longer valid, typically after a server reset.
    /// Run-control keeps this on a dedicated path (notify `FleetCommander`).
    #[error("unauthorized: {message}")]
    Unauthorized { message: String },

    /// 429: rate limited. `retry_after` carries the server's `Retry-After` hint
    /// when present. Transient.
    #[error("rate limit exceeded: retry after {retry_after:?}")]
    RateLimited { retry_after: Option<Duration> },

    /// 5xx: server-side / maintenance failure. Transient.
    #[error("server error {status}: {message}")]
    RetryableServer { status: u16, message: String },

    /// A non-retryable game/API error: bad state, invalid action, insufficient
    /// funds, 404, and any other status we do not special-case.
    #[error("api error: {0}")]
    Api(String),

    #[error("serde error: {0}")]
    Serde(#[from] serde_json::Error),
}

impl ClientError {
    pub fn is_unauthorized(&self) -> bool {
        match self {
            ClientError::Unauthorized { .. } => true,
            // Defensive: a transport-level error that still carries a 401 status.
            ClientError::Http(e) => e.status().is_some_and(|s| s.as_u16() == 401),
            _ => false,
        }
    }

    /// Whether the failure is transient and worth retrying automatically:
    /// communication errors, 429 rate limits, and 5xx server errors. This is the
    /// idempotent-read view; mutations must go through [`Self::is_retryable_under`].
    pub fn is_retryable(&self) -> bool {
        matches!(
            self,
            ClientError::Http(_)
                | ClientError::RateLimited { .. }
                | ClientError::RetryableServer { .. }
        )
    }

    /// Whether this error is safe to retry automatically under `policy`.
    ///
    /// For [`RetryPolicy::Idempotent`] this is exactly [`Self::is_retryable`].
    /// For [`RetryPolicy::Mutating`] only a 429 qualifies: the rate limiter
    /// rejects it before the mutation runs, so no change was applied. A 5xx or
    /// communication error is ambiguous — the server may have applied the
    /// change — so a non-idempotent request must *not* be replayed.
    pub fn is_retryable_under(&self, policy: RetryPolicy) -> bool {
        match policy {
            RetryPolicy::Idempotent => self.is_retryable(),
            RetryPolicy::Mutating => matches!(self, ClientError::RateLimited { .. }),
        }
    }

    /// Whether a failed mutation left the outcome *ambiguous*: the request may
    /// have reached the game and applied server-side even though the client saw
    /// an error. True for 5xx and communication failures (the response was lost
    /// or the server faulted mid-write); false for a 429 (rejected before the
    /// action ran) and for deterministic game errors (e.g. insufficient funds),
    /// where the prior state is known to still hold. Callers use this to decide
    /// whether to re-read authoritative state before acting again.
    pub fn is_ambiguous_mutation(&self) -> bool {
        matches!(
            self,
            ClientError::Http(_) | ClientError::RetryableServer { .. }
        )
    }

    /// Server-suggested delay before retrying, when one was provided (the
    /// `Retry-After` header on a 429).
    pub fn retry_after(&self) -> Option<Duration> {
        match self {
            ClientError::RateLimited { retry_after } => *retry_after,
            _ => None,
        }
    }

    pub fn class(&self) -> &'static str {
        match self {
            ClientError::Http(_) => "communication",
            ClientError::Unauthorized { .. } => "unauthorized",
            ClientError::RateLimited { .. } => "rate_limited",
            ClientError::RetryableServer { .. } => "server",
            ClientError::Api(_) => "api",
            ClientError::Serde(_) => "serde",
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn server_error() -> ClientError {
        ClientError::RetryableServer {
            status: 500,
            message: "boom".into(),
        }
    }

    fn rate_limited() -> ClientError {
        ClientError::RateLimited {
            retry_after: Some(Duration::from_secs(1)),
        }
    }

    #[test]
    fn idempotent_policy_retries_every_transient_error() {
        for err in [
            rate_limited(),
            server_error(),
            ClientError::RateLimited { retry_after: None },
        ] {
            assert!(err.is_retryable_under(RetryPolicy::Idempotent));
        }
    }

    #[test]
    fn mutating_policy_only_retries_rate_limit() {
        // 429 is rejected before the mutation runs → safe to replay.
        assert!(rate_limited().is_retryable_under(RetryPolicy::Mutating));
        // 5xx and communication errors are ambiguous → must not replay a POST.
        assert!(!server_error().is_retryable_under(RetryPolicy::Mutating));
        // A bad-request style game error is never retried under either policy.
        assert!(!ClientError::Api("nope".into()).is_retryable_under(RetryPolicy::Mutating));
        assert!(!ClientError::Api("nope".into()).is_retryable_under(RetryPolicy::Idempotent));
    }

    #[test]
    fn ambiguous_mutation_flags_only_could_have_applied_failures() {
        // The server may have applied the write before faulting / dropping us.
        assert!(server_error().is_ambiguous_mutation());
        // Rejected before the action ran → prior state still holds.
        assert!(!rate_limited().is_ambiguous_mutation());
        // Deterministic game rejection → prior state still holds.
        assert!(!ClientError::Api("insufficient funds".into()).is_ambiguous_mutation());
    }
}