ticksupply 0.1.0

Official Rust client for the Ticksupply market data API
Documentation
//! error — Typed errors returned by this crate.
//!
//! Every fallible operation returns [`Result<T>`], which is [`std::result::Result<T, Error>`].

use serde::Deserialize;

/// The crate-wide error type.
///
/// API errors are mapped from the server's error response body
/// (`{ "error": { "code", "message", "details?" } }`) to typed variants when
/// the `code` matches a known value. Unknown codes fall through to
/// [`Error::Api`] so new server-side codes never break clients.
///
/// Every API variant carries the `X-Request-Id` header value when present —
/// include it in support tickets.
///
/// # Examples
///
/// ```
/// use ticksupply::Error;
/// # fn pretend() -> Result<(), Error> {
/// # Err(Error::NotFound { message: "demo".into(), request_id: None })
/// # }
/// match pretend() {
///     Err(Error::NotFound { message, .. }) => eprintln!("missing: {message}"),
///     Err(Error::RateLimited { retry_after, .. }) => eprintln!("retry after {retry_after:?}"),
///     _ => {}
/// }
/// ```
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The requested resource does not exist (server code `not_found`).
    #[error("not found: {message}")]
    NotFound {
        /// Server-provided message.
        message: String,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// The API key is missing, malformed, or rejected (server code `unauthenticated`).
    #[error("authentication failed: {message}")]
    Authentication {
        /// Server-provided message.
        message: String,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// The caller is authenticated but not permitted to access the resource
    /// (server code `permission_denied`).
    #[error("permission denied: {message}")]
    PermissionDenied {
        /// Server-provided message.
        message: String,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// Request was throttled (server code `rate_limited`).
    #[error("rate limited (retry after {retry_after:?}s): {message}")]
    RateLimited {
        /// Server-provided message.
        message: String,
        /// Seconds the caller should wait before retrying (from `Retry-After`).
        retry_after: Option<u64>,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// Request was rejected at validation (server code `invalid_argument`).
    #[error("validation failed: {message}")]
    Validation {
        /// Server-provided message.
        message: String,
        /// Optional structured details from the server.
        details: Option<serde_json::Value>,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// Resource already exists or conflicts with existing state
    /// (server code `already_exists`).
    #[error("already exists: {message}")]
    AlreadyExists {
        /// Server-provided message.
        message: String,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// Billing action denied — payment required to proceed
    /// (server code `payment_required`).
    #[error("payment required: {message}")]
    PaymentRequired {
        /// Server-provided message.
        message: String,
        /// Optional structured details from the server (e.g. reason code).
        details: Option<serde_json::Value>,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// Any other API error — unknown server-side code or HTTP status without
    /// a typed variant in this enum. Also used for server codes `internal`
    /// and `unavailable`.
    #[error("API error [{code}] (HTTP {status}): {message}")]
    Api {
        /// Server-provided error code string.
        code: String,
        /// Server-provided message.
        message: String,
        /// HTTP status code.
        status: u16,
        /// `X-Request-Id` header value, if present.
        request_id: Option<String>,
    },

    /// Transport-level failure (DNS, TCP, TLS, I/O).
    #[error("network error: {0}")]
    Network(#[source] reqwest::Error),

    /// Failed to decode a successful response body.
    #[error("decode error: {0}")]
    Decode(#[source] serde_json::Error),

    /// Invalid client configuration.
    #[error("invalid configuration: {0}")]
    Config(String),
}

/// Alias for [`std::result::Result<T, Error>`] used throughout the crate.
pub type Result<T> = std::result::Result<T, Error>;

/// Wire shape of server error responses: `{ "error": { "code", "message", "details?" } }`.
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEnvelope {
    pub error: ErrorBody,
}

#[derive(Debug, Deserialize)]
pub(crate) struct ErrorBody {
    pub code: String,
    pub message: String,
    pub details: Option<serde_json::Value>,
}

/// Translate a parsed error envelope plus HTTP metadata into a typed [`Error`] variant.
///
/// The server codes handled here match the public API's documented error code
/// enumeration. Codes `internal` and `unavailable` intentionally fall through
/// to [`Error::Api`] — there is no dedicated variant for transient server
/// faults, and treating them as generic API errors keeps retry logic out of
/// pattern matches on this type.
pub(crate) fn map_error(
    status: u16,
    body: ErrorBody,
    request_id: Option<String>,
    retry_after: Option<u64>,
) -> Error {
    match body.code.as_str() {
        "not_found" => Error::NotFound {
            message: body.message,
            request_id,
        },
        "unauthenticated" => Error::Authentication {
            message: body.message,
            request_id,
        },
        "permission_denied" => Error::PermissionDenied {
            message: body.message,
            request_id,
        },
        "rate_limited" => Error::RateLimited {
            message: body.message,
            retry_after,
            request_id,
        },
        "invalid_argument" => Error::Validation {
            message: body.message,
            details: body.details,
            request_id,
        },
        "already_exists" => Error::AlreadyExists {
            message: body.message,
            request_id,
        },
        "payment_required" => Error::PaymentRequired {
            message: body.message,
            details: body.details,
            request_id,
        },
        _ => Error::Api {
            code: body.code,
            message: body.message,
            status,
            request_id,
        },
    }
}

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

    #[test]
    fn maps_not_found() {
        let body = ErrorBody {
            code: "not_found".into(),
            message: "sub_x does not exist".into(),
            details: None,
        };
        let err = map_error(404, body, Some("req_abc".into()), None);
        match err {
            Error::NotFound {
                message,
                request_id,
            } => {
                assert_eq!(message, "sub_x does not exist");
                assert_eq!(request_id.as_deref(), Some("req_abc"));
            }
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn maps_rate_limited_with_retry_after() {
        let body = ErrorBody {
            code: "rate_limited".into(),
            message: "slow down".into(),
            details: None,
        };
        let err = map_error(429, body, None, Some(30));
        match err {
            Error::RateLimited { retry_after, .. } => assert_eq!(retry_after, Some(30)),
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn unknown_code_falls_through_to_api_variant() {
        let body = ErrorBody {
            code: "brand_new_code".into(),
            message: "???".into(),
            details: None,
        };
        let err = map_error(418, body, None, None);
        match err {
            Error::Api { code, status, .. } => {
                assert_eq!(code, "brand_new_code");
                assert_eq!(status, 418);
            }
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn maps_unauthenticated() {
        let body = ErrorBody {
            code: "unauthenticated".into(),
            message: "bad key".into(),
            details: None,
        };
        match map_error(401, body, None, None) {
            Error::Authentication { .. } => {}
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn maps_permission_denied() {
        let body = ErrorBody {
            code: "permission_denied".into(),
            message: "nope".into(),
            details: None,
        };
        match map_error(403, body, None, None) {
            Error::PermissionDenied { .. } => {}
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn maps_invalid_argument() {
        let body = ErrorBody {
            code: "invalid_argument".into(),
            message: "bad".into(),
            details: Some(serde_json::json!({"field": "x"})),
        };
        match map_error(400, body, None, None) {
            Error::Validation { details, .. } => assert!(details.is_some()),
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn maps_already_exists() {
        let body = ErrorBody {
            code: "already_exists".into(),
            message: "dupe".into(),
            details: None,
        };
        match map_error(409, body, None, None) {
            Error::AlreadyExists { .. } => {}
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn maps_payment_required() {
        let body = ErrorBody {
            code: "payment_required".into(),
            message: "pay up".into(),
            details: None,
        };
        match map_error(402, body, None, None) {
            Error::PaymentRequired { .. } => {}
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[test]
    fn internal_and_unavailable_fall_through_to_api() {
        for code in ["internal", "unavailable"] {
            let body = ErrorBody {
                code: code.into(),
                message: "oops".into(),
                details: None,
            };
            match map_error(500, body, None, None) {
                Error::Api { code: c, .. } => assert_eq!(c, code),
                other => panic!("wrong variant for {code}: {other:?}"),
            }
        }
    }
}