use std::fmt;
use std::time::Duration;
use serde::{de::DeserializeOwned, Serialize};
use tokio::time::sleep;
use crate::error::{ApiError, Error, Result};
use crate::quick_add::{QuickAddRequest, QuickAddResponse};
use crate::sync::{SyncRequest, SyncResponse};
const BASE_URL: &str = "https://api.todoist.com/api/v1";
const DEFAULT_INITIAL_BACKOFF_SECS: u64 = 1;
const DEFAULT_MAX_BACKOFF_SECS: u64 = 30;
const DEFAULT_MAX_RETRIES: u32 = 3;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Clone, Debug)]
struct RetryConfig {
max_retries: u32,
initial_backoff: Duration,
max_backoff: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: Duration::from_secs(DEFAULT_INITIAL_BACKOFF_SECS),
max_backoff: Duration::from_secs(DEFAULT_MAX_BACKOFF_SECS),
}
}
}
#[derive(Clone, Debug)]
pub struct TodoistClientBuilder {
token: String,
base_url: String,
max_retries: u32,
initial_backoff: Duration,
max_backoff: Duration,
request_timeout: Duration,
}
impl TodoistClientBuilder {
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
base_url: BASE_URL.to_string(),
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: Duration::from_secs(DEFAULT_INITIAL_BACKOFF_SECS),
max_backoff: Duration::from_secs(DEFAULT_MAX_BACKOFF_SECS),
request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
}
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
pub fn initial_backoff(mut self, initial_backoff: Duration) -> Self {
self.initial_backoff = initial_backoff;
self
}
pub fn max_backoff(mut self, max_backoff: Duration) -> Self {
self.max_backoff = max_backoff;
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn build(self) -> TodoistClient {
TodoistClient {
token: self.token,
http_client: reqwest::Client::builder()
.timeout(self.request_timeout)
.build()
.expect("Failed to build HTTP client"),
base_url: self.base_url,
retry_config: RetryConfig {
max_retries: self.max_retries,
initial_backoff: self.initial_backoff,
max_backoff: self.max_backoff,
},
}
}
}
#[derive(Clone)]
pub struct TodoistClient {
token: String,
http_client: reqwest::Client,
base_url: String,
retry_config: RetryConfig,
}
impl TodoistClient {
pub fn new(token: impl Into<String>) -> Self {
TodoistClientBuilder::new(token).build()
}
pub fn with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Self {
TodoistClientBuilder::new(token).base_url(base_url).build()
}
pub fn builder(token: impl Into<String>) -> TodoistClientBuilder {
TodoistClientBuilder::new(token)
}
pub fn token(&self) -> &str {
&self.token
}
pub fn http_client(&self) -> &reqwest::Client {
&self.http_client
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn max_retries(&self) -> u32 {
self.retry_config.max_retries
}
pub fn initial_backoff(&self) -> Duration {
self.retry_config.initial_backoff
}
pub fn max_backoff(&self) -> Duration {
self.retry_config.max_backoff
}
fn calculate_backoff(&self, attempt: u32, retry_after: Option<u64>) -> Duration {
let max_backoff_secs = self.retry_config.max_backoff.as_secs();
if let Some(secs) = retry_after {
Duration::from_secs(secs.min(max_backoff_secs))
} else {
let initial_secs = self.retry_config.initial_backoff.as_secs();
let backoff_secs = initial_secs.saturating_mul(1 << attempt);
Duration::from_secs(backoff_secs.min(max_backoff_secs))
}
}
pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
let url = format!("{}{}", self.base_url, endpoint);
let max_retries = self.retry_config.max_retries;
for attempt in 0..=max_retries {
let response = self
.http_client
.get(&url)
.bearer_auth(&self.token)
.send()
.await?;
match self
.handle_response_with_retry(response, attempt, max_retries)
.await
{
Ok(RetryDecision::Success(value)) => return Ok(value),
Ok(RetryDecision::Retry { retry_after }) => {
let backoff = self.calculate_backoff(attempt, retry_after);
sleep(backoff).await;
}
Err(e) => return Err(e),
}
}
Err(Error::Api(ApiError::RateLimit { retry_after: None }))
}
pub async fn post<T: DeserializeOwned, B: Serialize>(
&self,
endpoint: &str,
body: &B,
) -> Result<T> {
let url = format!("{}{}", self.base_url, endpoint);
let max_retries = self.retry_config.max_retries;
for attempt in 0..=max_retries {
let response = self
.http_client
.post(&url)
.bearer_auth(&self.token)
.json(body)
.send()
.await?;
match self
.handle_response_with_retry(response, attempt, max_retries)
.await
{
Ok(RetryDecision::Success(value)) => return Ok(value),
Ok(RetryDecision::Retry { retry_after }) => {
let backoff = self.calculate_backoff(attempt, retry_after);
sleep(backoff).await;
}
Err(e) => return Err(e),
}
}
Err(Error::Api(ApiError::RateLimit { retry_after: None }))
}
pub async fn post_empty<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T> {
let url = format!("{}{}", self.base_url, endpoint);
let max_retries = self.retry_config.max_retries;
for attempt in 0..=max_retries {
let response = self
.http_client
.post(&url)
.bearer_auth(&self.token)
.send()
.await?;
match self
.handle_response_with_retry(response, attempt, max_retries)
.await
{
Ok(RetryDecision::Success(value)) => return Ok(value),
Ok(RetryDecision::Retry { retry_after }) => {
let backoff = self.calculate_backoff(attempt, retry_after);
sleep(backoff).await;
}
Err(e) => return Err(e),
}
}
Err(Error::Api(ApiError::RateLimit { retry_after: None }))
}
pub async fn delete(&self, endpoint: &str) -> Result<()> {
let url = format!("{}{}", self.base_url, endpoint);
let max_retries = self.retry_config.max_retries;
for attempt in 0..=max_retries {
let response = self
.http_client
.delete(&url)
.bearer_auth(&self.token)
.send()
.await?;
match self
.handle_empty_response_with_retry(response, attempt, max_retries)
.await
{
Ok(RetryDecision::Success(())) => return Ok(()),
Ok(RetryDecision::Retry { retry_after }) => {
let backoff = self.calculate_backoff(attempt, retry_after);
sleep(backoff).await;
}
Err(e) => return Err(e),
}
}
Err(Error::Api(ApiError::RateLimit { retry_after: None }))
}
pub async fn sync(&self, request: SyncRequest) -> Result<SyncResponse> {
let url = format!("{}/sync", self.base_url);
let max_retries = self.retry_config.max_retries;
for attempt in 0..=max_retries {
let response = self
.http_client
.post(&url)
.bearer_auth(&self.token)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(request.to_form_body())
.send()
.await?;
match self
.handle_response_with_retry(response, attempt, max_retries)
.await
{
Ok(RetryDecision::Success(value)) => return Ok(value),
Ok(RetryDecision::Retry { retry_after }) => {
let backoff = self.calculate_backoff(attempt, retry_after);
sleep(backoff).await;
}
Err(e) => return Err(e),
}
}
Err(Error::Api(ApiError::RateLimit { retry_after: None }))
}
pub async fn quick_add(&self, request: QuickAddRequest) -> Result<QuickAddResponse> {
let url = format!("{}/tasks/quick", self.base_url);
let max_retries = self.retry_config.max_retries;
for attempt in 0..=max_retries {
let response = self
.http_client
.post(&url)
.bearer_auth(&self.token)
.json(&request)
.send()
.await?;
match self
.handle_response_with_retry(response, attempt, max_retries)
.await
{
Ok(RetryDecision::Success(value)) => return Ok(value),
Ok(RetryDecision::Retry { retry_after }) => {
let backoff = self.calculate_backoff(attempt, retry_after);
sleep(backoff).await;
}
Err(e) => return Err(e),
}
}
Err(Error::Api(ApiError::RateLimit { retry_after: None }))
}
async fn handle_response_with_retry<T: DeserializeOwned>(
&self,
response: reqwest::Response,
attempt: u32,
max_retries: u32,
) -> Result<RetryDecision<T>> {
let status = response.status();
if status.is_success() {
let body = response.json::<T>().await?;
return Ok(RetryDecision::Success(body));
}
if status.as_u16() == 429 && attempt < max_retries {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
return Ok(RetryDecision::Retry { retry_after });
}
Err(self.parse_error_response(response).await)
}
async fn handle_empty_response_with_retry(
&self,
response: reqwest::Response,
attempt: u32,
max_retries: u32,
) -> Result<RetryDecision<()>> {
let status = response.status();
if status.is_success() {
return Ok(RetryDecision::Success(()));
}
if status.as_u16() == 429 && attempt < max_retries {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
return Ok(RetryDecision::Retry { retry_after });
}
Err(self.parse_error_response(response).await)
}
async fn parse_error_response(&self, response: reqwest::Response) -> Error {
let status = response.status();
let status_code = status.as_u16();
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
let message = response.text().await.unwrap_or_default();
let api_error = match status_code {
401 | 403 => ApiError::Auth {
message: if message.is_empty() {
"Authentication failed".to_string()
} else {
message
},
},
404 => ApiError::NotFound {
resource: "resource".to_string(),
id: "unknown".to_string(),
},
429 => ApiError::RateLimit { retry_after },
400 => ApiError::Validation {
field: None,
message: if message.is_empty() {
"Bad request".to_string()
} else {
message
},
},
_ => ApiError::Http {
status: status_code,
message: if message.is_empty() {
status
.canonical_reason()
.unwrap_or("Unknown error")
.to_string()
} else {
message
},
},
};
Error::Api(api_error)
}
}
enum RetryDecision<T> {
Success(T),
Retry { retry_after: Option<u64> },
}
impl fmt::Debug for TodoistClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TodoistClient")
.field("token", &"[REDACTED]")
.field("http_client", &self.http_client)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_todoist_client_struct_exists() {
let _client: TodoistClient;
}
#[test]
fn test_todoist_client_new_accepts_token() {
let token = "test-api-token-12345";
let client = TodoistClient::new(token);
let _ = client;
}
#[test]
fn test_todoist_client_stores_token() {
let token = "my-secret-token";
let client = TodoistClient::new(token);
assert_eq!(client.token(), token);
}
#[test]
fn test_todoist_client_has_http_client() {
let client = TodoistClient::new("test-token");
let _http_client = client.http_client();
}
#[test]
fn test_todoist_client_is_clone() {
let client = TodoistClient::new("test-token");
let _cloned = client.clone();
}
#[test]
fn test_todoist_client_is_debug() {
let client = TodoistClient::new("test-token");
let debug_str = format!("{:?}", client);
assert!(
!debug_str.contains("test-token"),
"Token should be redacted in debug output"
);
}
#[test]
fn test_todoist_client_default_base_url() {
let client = TodoistClient::new("test-token");
assert_eq!(client.base_url(), BASE_URL);
}
#[test]
fn test_todoist_client_with_custom_base_url() {
let client = TodoistClient::with_base_url("test-token", "https://test.example.com");
assert_eq!(client.base_url(), "https://test.example.com");
}
#[test]
fn test_calculate_backoff_with_retry_after() {
let client = TodoistClient::new("test-token");
let backoff = client.calculate_backoff(0, Some(5));
assert_eq!(backoff, Duration::from_secs(5));
let backoff = client.calculate_backoff(0, Some(60));
assert_eq!(backoff, Duration::from_secs(DEFAULT_MAX_BACKOFF_SECS));
}
#[test]
fn test_calculate_backoff_exponential() {
let client = TodoistClient::new("test-token");
let backoff = client.calculate_backoff(0, None);
assert_eq!(backoff, Duration::from_secs(1));
let backoff = client.calculate_backoff(1, None);
assert_eq!(backoff, Duration::from_secs(2));
let backoff = client.calculate_backoff(2, None);
assert_eq!(backoff, Duration::from_secs(4));
let backoff = client.calculate_backoff(3, None);
assert_eq!(backoff, Duration::from_secs(8));
}
#[test]
fn test_calculate_backoff_caps_at_max() {
let client = TodoistClient::new("test-token");
let backoff = client.calculate_backoff(10, None);
assert_eq!(backoff, Duration::from_secs(DEFAULT_MAX_BACKOFF_SECS));
}
#[test]
fn test_default_timeout_constant() {
assert_eq!(DEFAULT_TIMEOUT_SECS, 30);
}
#[test]
fn test_builder_default_values() {
let client = TodoistClientBuilder::new("test-token").build();
assert_eq!(client.token(), "test-token");
assert_eq!(client.base_url(), BASE_URL);
assert_eq!(client.max_retries(), DEFAULT_MAX_RETRIES);
assert_eq!(
client.initial_backoff(),
Duration::from_secs(DEFAULT_INITIAL_BACKOFF_SECS)
);
assert_eq!(
client.max_backoff(),
Duration::from_secs(DEFAULT_MAX_BACKOFF_SECS)
);
}
#[test]
fn test_builder_custom_max_retries() {
let client = TodoistClientBuilder::new("test-token")
.max_retries(5)
.build();
assert_eq!(client.max_retries(), 5);
}
#[test]
fn test_builder_custom_initial_backoff() {
let client = TodoistClientBuilder::new("test-token")
.initial_backoff(Duration::from_millis(500))
.build();
assert_eq!(client.initial_backoff(), Duration::from_millis(500));
}
#[test]
fn test_builder_custom_max_backoff() {
let client = TodoistClientBuilder::new("test-token")
.max_backoff(Duration::from_secs(60))
.build();
assert_eq!(client.max_backoff(), Duration::from_secs(60));
}
#[test]
fn test_builder_chaining() {
let client = TodoistClientBuilder::new("test-token")
.base_url("https://custom.example.com")
.max_retries(5)
.initial_backoff(Duration::from_millis(500))
.max_backoff(Duration::from_secs(60))
.request_timeout(Duration::from_secs(45))
.build();
assert_eq!(client.base_url(), "https://custom.example.com");
assert_eq!(client.max_retries(), 5);
assert_eq!(client.initial_backoff(), Duration::from_millis(500));
assert_eq!(client.max_backoff(), Duration::from_secs(60));
}
#[test]
fn test_client_builder_method() {
let client = TodoistClient::builder("test-token").max_retries(10).build();
assert_eq!(client.max_retries(), 10);
}
#[test]
fn test_custom_initial_backoff_affects_calculation() {
let client = TodoistClientBuilder::new("test-token")
.initial_backoff(Duration::from_secs(2))
.build();
let backoff = client.calculate_backoff(0, None);
assert_eq!(backoff, Duration::from_secs(2));
let backoff = client.calculate_backoff(1, None);
assert_eq!(backoff, Duration::from_secs(4));
}
#[test]
fn test_custom_max_backoff_caps_calculation() {
let client = TodoistClientBuilder::new("test-token")
.max_backoff(Duration::from_secs(10))
.build();
let backoff = client.calculate_backoff(10, None);
assert_eq!(backoff, Duration::from_secs(10));
let backoff = client.calculate_backoff(0, Some(60));
assert_eq!(backoff, Duration::from_secs(10));
}
}
#[cfg(test)]
mod wiremock_tests {
use super::*;
use serde::Deserialize;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
#[derive(Debug, Deserialize, PartialEq)]
struct TestTask {
id: String,
content: String,
}
#[tokio::test]
async fn test_get_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tasks/123"))
.and(header("Authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "123",
"content": "Test task"
})))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let task: TestTask = client.get("/tasks/123").await.unwrap();
assert_eq!(task.id, "123");
assert_eq!(task.content, "Test task");
}
#[tokio::test]
async fn test_get_retry_on_429_then_success() {
let mock_server = MockServer::start().await;
let call_count = Arc::new(AtomicU32::new(0));
struct RetryThenSuccessResponder {
call_count: Arc<AtomicU32>,
}
impl Respond for RetryThenSuccessResponder {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count == 0 {
ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_string("Rate limited")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "123",
"content": "Test task"
}))
}
}
}
Mock::given(method("GET"))
.and(path("/tasks/123"))
.respond_with(RetryThenSuccessResponder {
call_count: call_count.clone(),
})
.expect(2)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let task: TestTask = client.get("/tasks/123").await.unwrap();
assert_eq!(task.id, "123");
assert_eq!(call_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_get_fails_after_max_retries() {
let mock_server = MockServer::start().await;
let call_count = Arc::new(AtomicU32::new(0));
struct AlwaysRateLimitResponder {
call_count: Arc<AtomicU32>,
}
impl Respond for AlwaysRateLimitResponder {
fn respond(&self, _request: &Request) -> ResponseTemplate {
self.call_count.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_string("Rate limited")
}
}
Mock::given(method("GET"))
.and(path("/tasks/123"))
.respond_with(AlwaysRateLimitResponder {
call_count: call_count.clone(),
})
.expect(4) .mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let result: Result<TestTask> = client.get("/tasks/123").await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Api(ApiError::RateLimit { .. }) => {}
e => panic!("Expected RateLimit error, got: {:?}", e),
}
assert_eq!(call_count.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn test_post_success() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/tasks"))
.and(header("Authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "456",
"content": "New task"
})))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let task: TestTask = client
.post("/tasks", &serde_json::json!({"content": "New task"}))
.await
.unwrap();
assert_eq!(task.id, "456");
assert_eq!(task.content, "New task");
}
#[tokio::test]
async fn test_post_retry_on_429() {
let mock_server = MockServer::start().await;
let call_count = Arc::new(AtomicU32::new(0));
struct RetryThenSuccessResponder {
call_count: Arc<AtomicU32>,
}
impl Respond for RetryThenSuccessResponder {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count < 2 {
ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_string("Rate limited")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "456",
"content": "New task"
}))
}
}
}
Mock::given(method("POST"))
.and(path("/tasks"))
.respond_with(RetryThenSuccessResponder {
call_count: call_count.clone(),
})
.expect(3)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let task: TestTask = client
.post("/tasks", &serde_json::json!({"content": "New task"}))
.await
.unwrap();
assert_eq!(task.id, "456");
assert_eq!(call_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_delete_success() {
let mock_server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/tasks/123"))
.and(header("Authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let result = client.delete("/tasks/123").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_delete_retry_on_429() {
let mock_server = MockServer::start().await;
let call_count = Arc::new(AtomicU32::new(0));
struct RetryThenSuccessResponder {
call_count: Arc<AtomicU32>,
}
impl Respond for RetryThenSuccessResponder {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count == 0 {
ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_string("Rate limited")
} else {
ResponseTemplate::new(204)
}
}
}
Mock::given(method("DELETE"))
.and(path("/tasks/123"))
.respond_with(RetryThenSuccessResponder {
call_count: call_count.clone(),
})
.expect(2)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let result = client.delete("/tasks/123").await;
assert!(result.is_ok());
assert_eq!(call_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_post_empty_success() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/tasks/123/close"))
.and(header("Authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "123",
"content": "Completed task"
})))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let task: TestTask = client.post_empty("/tasks/123/close").await.unwrap();
assert_eq!(task.id, "123");
}
#[tokio::test]
async fn test_post_empty_retry_on_429() {
let mock_server = MockServer::start().await;
let call_count = Arc::new(AtomicU32::new(0));
struct RetryThenSuccessResponder {
call_count: Arc<AtomicU32>,
}
impl Respond for RetryThenSuccessResponder {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count == 0 {
ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_string("Rate limited")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "123",
"content": "Completed task"
}))
}
}
}
Mock::given(method("POST"))
.and(path("/tasks/123/close"))
.respond_with(RetryThenSuccessResponder {
call_count: call_count.clone(),
})
.expect(2)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let task: TestTask = client.post_empty("/tasks/123/close").await.unwrap();
assert_eq!(task.id, "123");
assert_eq!(call_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_non_retryable_errors_not_retried() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tasks/123"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
.expect(1) .mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let result: Result<TestTask> = client.get("/tasks/123").await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Api(ApiError::NotFound { .. }) => {}
e => panic!("Expected NotFound error, got: {:?}", e),
}
}
#[tokio::test]
async fn test_auth_errors_not_retried() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tasks/123"))
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let result: Result<TestTask> = client.get("/tasks/123").await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Api(ApiError::Auth { .. }) => {}
e => panic!("Expected Auth error, got: {:?}", e),
}
}
#[tokio::test]
async fn test_uses_retry_after_header() {
let mock_server = MockServer::start().await;
let call_count = Arc::new(AtomicU32::new(0));
struct RetryThenSuccessResponder {
call_count: Arc<AtomicU32>,
}
impl Respond for RetryThenSuccessResponder {
fn respond(&self, _request: &Request) -> ResponseTemplate {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count == 0 {
ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_string("Rate limited")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "123",
"content": "Test task"
}))
}
}
}
Mock::given(method("GET"))
.and(path("/tasks/123"))
.respond_with(RetryThenSuccessResponder {
call_count: call_count.clone(),
})
.expect(2)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri());
let start = std::time::Instant::now();
let task: TestTask = client.get("/tasks/123").await.unwrap();
let elapsed = start.elapsed();
assert_eq!(task.id, "123");
assert!(
elapsed >= Duration::from_millis(900),
"Expected delay of ~1s, got {:?}",
elapsed
);
}
#[tokio::test]
async fn test_client_timeout_on_slow_response() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tasks/slow"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({
"id": "123",
"content": "Test task"
}))
.set_delay(Duration::from_secs(5)),
) .mount(&mock_server)
.await;
let client = TodoistClientBuilder::new("test-token")
.base_url(mock_server.uri())
.request_timeout(Duration::from_secs(1))
.build();
let result: Result<TestTask> = client.get("/tasks/slow").await;
assert!(result.is_err(), "Expected timeout error");
match result {
Err(Error::Http(req_err)) => {
assert!(
req_err.is_timeout(),
"Expected timeout error, got: {:?}",
req_err
);
}
Err(e) => panic!("Expected HTTP timeout error, got: {:?}", e),
Ok(_) => panic!("Expected error, got success"),
}
}
}