use std::time::Duration;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct RateLimit {
pub limit: Option<u64>,
pub remaining: Option<u64>,
pub reset: Option<DateTime<Utc>>,
pub retry_after: Option<Duration>,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct ApiError {
pub status: u16,
pub code: String,
pub message: String,
pub field: Option<String>,
pub details: Option<serde_json::Value>,
pub request_id: Option<String>,
pub rate_limit: RateLimit,
pub body: Option<String>,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.field {
Some(field) => write!(f, "{} (field {:?})", self.message, field),
None => write!(f, "{}", self.message),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("spoo.me API error ({status}): {inner}", status = .0.status, inner = .0)]
Api(Box<ApiError>),
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("failed to decode response body: {0}")]
Decode(#[source] serde_json::Error),
#[error("session expired: refresh token was rejected")]
SessionExpired,
#[error("configuration error: {0}")]
Config(String),
}
impl Error {
pub fn api(&self) -> Option<&ApiError> {
match self {
Error::Api(e) => Some(e),
_ => None,
}
}
pub fn status(&self) -> Option<u16> {
self.api().map(|e| e.status)
}
pub fn code(&self) -> Option<&str> {
self.api().map(|e| e.code.as_str())
}
pub fn is_not_found(&self) -> bool {
self.status() == Some(404)
}
pub fn is_rate_limited(&self) -> bool {
self.status() == Some(429)
}
pub fn is_blocked(&self) -> bool {
self.status() == Some(451)
}
pub fn is_password_required(&self) -> bool {
self.status() == Some(401)
&& self
.code()
.is_some_and(|c| c == "password_required" || c == "invalid_password")
}
pub fn retry_after(&self) -> Option<Duration> {
self.api().and_then(|e| e.rate_limit.retry_after)
}
}
pub(crate) fn parse_retry_after(value: &str, now: DateTime<Utc>) -> Option<Duration> {
let value = value.trim();
if let Ok(secs) = value.parse::<u64>() {
return Some(Duration::from_secs(secs));
}
if let Ok(when) = DateTime::parse_from_rfc2822(value) {
let delta = when.with_timezone(&Utc) - now;
return delta.to_std().ok();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retry_after_parses_both_legal_forms() {
let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
parse_retry_after("120", now),
Some(Duration::from_secs(120))
);
assert_eq!(
parse_retry_after("Thu, 01 Jan 2026 00:00:30 GMT", now),
Some(Duration::from_secs(30))
);
assert_eq!(
parse_retry_after("Wed, 31 Dec 2025 23:59:00 GMT", now),
None
);
assert_eq!(parse_retry_after("not-a-value", now), None);
assert_eq!(parse_retry_after("", now), None);
}
}