1use crate::ynab::errors::{Error, ErrorResponse};
2use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
3use reqwest::RequestBuilder;
4use secrecy::ExposeSecret;
5use std::num::NonZeroU32;
6use std::sync::Arc;
7use std::time::Duration;
8
9#[derive(Debug, Clone, Copy)]
11pub struct RetryPolicy {
12 pub max_retries: u32,
13 pub base_delay: Duration,
14 pub max_delay: Duration,
15}
16
17#[derive(Debug)]
18pub struct Client {
20 pub(crate) base_url: reqwest::Url,
21 pub(crate) http_client: reqwest::Client,
22 pub(crate) limiter: Option<Arc<DefaultDirectRateLimiter>>,
23 #[allow(dead_code)]
24 api_key: secrecy::SecretBox<String>, pub(crate) timeout: Option<Duration>,
26 pub(crate) retry_policy: Option<RetryPolicy>,
27}
28
29impl Client {
30 pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
42 let api_key = secrecy::SecretBox::new(Box::new(api_key.into()));
43 let http_client = Self::build_http_client(api_key.expose_secret())?;
44 Ok(Self {
45 base_url: reqwest::Url::parse("https://api.ynab.com/v1")
46 .expect("hardcoded base URL is always valid"),
47 http_client,
48 limiter: None,
49 api_key,
50 timeout: None,
51 retry_policy: None,
52 })
53 }
54
55 fn build_http_client(api_key: &str) -> Result<reqwest::Client, Error> {
56 let mut headers = reqwest::header::HeaderMap::new();
57 headers.insert(
58 reqwest::header::AUTHORIZATION,
59 format!("Bearer {}", api_key)
60 .parse()
61 .expect("api key must be valid ASCII"),
62 );
63 let builder = reqwest::Client::builder().default_headers(headers);
64 builder.build().map_err(Into::into)
65 }
66
67 pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, Error> {
81 self.timeout = Some(timeout);
82 Ok(self)
83 }
84
85 pub fn with_base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, Error> {
87 self.base_url = reqwest::Url::parse(base_url.as_ref())?;
88 Ok(self)
89 }
90
91 pub fn with_rate_limiter(
112 mut self,
113 requests_per_hour: usize,
114 burst_volume: Option<usize>,
115 ) -> Result<Self, Error> {
116 let requests = NonZeroU32::new(requests_per_hour as u32)
117 .ok_or_else(|| Error::InvalidRateLimit("requests_per_hour must be non-zero".into()))?;
118
119 let quota = match burst_volume {
120 None => Quota::per_hour(requests),
121 Some(burst) => {
122 let effective = (requests_per_hour as u32)
123 .checked_sub(burst as u32)
124 .ok_or_else(|| {
125 Error::InvalidRateLimit(
126 "requests_per_hour must be greater than burst_volume".into(),
127 )
128 })?;
129 let effective_rate = NonZeroU32::new(effective).ok_or_else(|| {
130 Error::InvalidRateLimit(
131 "requests_per_hour - burst_volume must be non-zero".into(),
132 )
133 })?;
134 let burst = NonZeroU32::new(burst as u32).ok_or_else(|| {
135 Error::InvalidRateLimit("burst_volume must be non-zero".into())
136 })?;
137 Quota::per_hour(effective_rate).allow_burst(burst)
138 }
139 };
140
141 self.limiter = Some(Arc::new(RateLimiter::direct(quota)));
142 Ok(self)
143 }
144
145 pub fn with_retry(
169 mut self,
170 max_retries: u32,
171 base_delay: Duration,
172 max_delay: Duration,
173 ) -> Self {
174 self.retry_policy = Some(RetryPolicy {
175 max_retries,
176 base_delay,
177 max_delay,
178 });
179 self
180 }
181
182 fn next_retry_delay(&self, attempt: u32) -> Option<Duration> {
185 let policy = self.retry_policy?;
186 if attempt >= policy.max_retries {
187 return None;
188 }
189 let exp = policy
190 .base_delay
191 .saturating_mul(2u32.saturating_pow(attempt));
192 Some(exp.min(policy.max_delay))
193 }
194
195 pub(crate) fn make_url(&self, endpoint: &str) -> reqwest::Url {
196 let mut url = self.base_url.clone();
197 url.path_segments_mut()
198 .expect("base URL must be a valid base")
199 .extend(endpoint.split('/').filter(|s| !s.is_empty()));
200 url
201 }
202
203 async fn prepare(&self, method: reqwest::Method, endpoint: &str) -> RequestBuilder {
205 if let Some(limiter) = &self.limiter {
206 let start = std::time::Instant::now();
207 limiter.until_ready().await;
208 tracing::debug!(
209 waited_ms = start.elapsed().as_millis() as u64,
210 "rate limiter delayed request"
211 );
212 }
213 let url = self.make_url(endpoint);
214 self.http_client.request(method, url)
215 }
216
217 async fn send_json<T: serde::de::DeserializeOwned>(
219 &self,
220 method: reqwest::Method,
221 mut builder: RequestBuilder,
222 ) -> Result<T, Error> {
223 if let Some(t) = self.timeout {
224 builder = builder.timeout(t);
225 }
226 let mut attempt: u32 = 0;
227 loop {
228 let start = std::time::Instant::now();
229 let req = builder
231 .try_clone()
232 .expect("body must be clonable for retry");
233 let res = match req.send().await {
234 Ok(res) => res,
235 Err(e) => {
236 if method == reqwest::Method::GET
239 && let Some(delay) = self.next_retry_delay(attempt)
240 {
241 tracing::debug!(
242 attempt,
243 delay_ms = delay.as_millis() as u64,
244 error = %e,
245 "retrying after connection error"
246 );
247 tokio::time::sleep(delay).await;
248 attempt += 1;
249 continue;
250 }
251 return Err(e.into());
252 }
253 };
254 let status = res.status();
255 let elapsed_ms = start.elapsed().as_millis() as u64;
256
257 if !status.is_success() {
258 let retry_after = res
259 .headers()
260 .get(reqwest::header::RETRY_AFTER)
261 .and_then(|v| v.to_str().ok())
262 .and_then(|s| s.parse::<u64>().ok())
263 .map(Duration::from_secs);
264 let err_body: ErrorResponse = res.json().await?;
265 tracing::warn!(status = status.as_u16(), elapsed_ms, error = %err_body.error, "YNAB API error");
266
267 let retryable_status = matches!(
270 status,
271 reqwest::StatusCode::TOO_MANY_REQUESTS
272 | reqwest::StatusCode::SERVICE_UNAVAILABLE
273 );
274 if retryable_status && let Some(backoff) = self.next_retry_delay(attempt) {
275 let delay = retry_after.unwrap_or(backoff);
276 tracing::debug!(
277 attempt,
278 delay_ms = delay.as_millis() as u64,
279 status = status.as_u16(),
280 "retrying request"
281 );
282 tokio::time::sleep(delay).await;
283 attempt += 1;
284 continue;
285 }
286 return Err(Error::new_api_error(status, err_body.error));
287 }
288 tracing::debug!(status = status.as_u16(), elapsed_ms, "request succeeded");
289 return res.json().await.map_err(Into::into);
290 }
291 }
292
293 #[tracing::instrument(skip(self, params), fields(endpoint = %endpoint))]
294 pub(crate) async fn get<T: serde::de::DeserializeOwned, Q: serde::ser::Serialize + ?Sized>(
295 &self,
296 endpoint: &str,
297 params: Option<&Q>,
298 ) -> Result<T, Error> {
299 let mut builder = self.prepare(reqwest::Method::GET, endpoint).await;
300 if let Some(p) = params {
301 builder = builder.query(p);
302 }
303 self.send_json(reqwest::Method::GET, builder).await
304 }
305
306 #[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
307 pub(crate) async fn post<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
308 &self,
309 endpoint: &str,
310 body: B,
311 ) -> Result<T, Error> {
312 let builder = self
313 .prepare(reqwest::Method::POST, endpoint)
314 .await
315 .json(&body);
316 self.send_json(reqwest::Method::POST, builder).await
317 }
318
319 #[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
320 pub(crate) async fn patch<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
321 &self,
322 endpoint: &str,
323 body: B,
324 ) -> Result<T, Error> {
325 let builder = self
326 .prepare(reqwest::Method::PATCH, endpoint)
327 .await
328 .json(&body);
329 self.send_json(reqwest::Method::PATCH, builder).await
330 }
331
332 #[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
333 pub(crate) async fn put<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
334 &self,
335 endpoint: &str,
336 body: B,
337 ) -> Result<T, Error> {
338 let builder = self
339 .prepare(reqwest::Method::PUT, endpoint)
340 .await
341 .json(&body);
342 self.send_json(reqwest::Method::PUT, builder).await
343 }
344
345 #[tracing::instrument(skip(self), fields(endpoint = %endpoint))]
346 pub(crate) async fn delete<T: serde::de::DeserializeOwned>(
347 &self,
348 endpoint: &str,
349 ) -> Result<T, Error> {
350 let builder = self.prepare(reqwest::Method::DELETE, endpoint).await;
351 self.send_json(reqwest::Method::DELETE, builder).await
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::ynab::common::NO_PARAMS;
359 use crate::ynab::testutil::{error_body, new_test_client};
360 use serde::Deserialize;
361 use wiremock::matchers::{method, path};
362 use wiremock::{Mock, ResponseTemplate};
363
364 #[derive(Debug, Deserialize)]
365 struct Pong {
366 ok: bool,
367 }
368
369 fn too_many_requests_body() -> serde_json::Value {
370 error_body("429", "too_many_requests", "Too many requests")
371 }
372
373 #[tokio::test]
374 async fn retries_on_429_then_succeeds() {
375 let (client, server) = new_test_client().await;
376 let client = client.with_retry(3, Duration::from_millis(5), Duration::from_millis(50));
377
378 Mock::given(method("GET"))
379 .and(path("/plans"))
380 .respond_with(ResponseTemplate::new(429).set_body_json(too_many_requests_body()))
381 .up_to_n_times(2)
382 .expect(2)
383 .mount(&server)
384 .await;
385 Mock::given(method("GET"))
386 .and(path("/plans"))
387 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
388 .expect(1)
389 .mount(&server)
390 .await;
391
392 let result: Pong = client.get("plans", NO_PARAMS).await.unwrap();
393 assert!(result.ok);
394 }
395
396 #[tokio::test]
397 async fn stops_retrying_once_budget_exhausted() {
398 let (client, server) = new_test_client().await;
399 let client = client.with_retry(2, Duration::from_millis(5), Duration::from_millis(50));
400
401 Mock::given(method("GET"))
402 .and(path("/plans"))
403 .respond_with(ResponseTemplate::new(429).set_body_json(too_many_requests_body()))
404 .expect(3)
406 .mount(&server)
407 .await;
408
409 let result: Result<Pong, Error> = client.get("plans", NO_PARAMS).await;
410 assert!(matches!(result, Err(Error::RateLimited(_))));
411 }
412
413 #[tokio::test]
414 async fn no_retry_without_policy_configured() {
415 let (client, server) = new_test_client().await;
416
417 Mock::given(method("GET"))
418 .and(path("/plans"))
419 .respond_with(ResponseTemplate::new(429).set_body_json(too_many_requests_body()))
420 .expect(1)
421 .mount(&server)
422 .await;
423
424 let result: Result<Pong, Error> = client.get("plans", NO_PARAMS).await;
425 assert!(matches!(result, Err(Error::RateLimited(_))));
426 }
427}