Skip to main content

lago_client/
client.rs

1use reqwest::{Client as HttpClient, Response};
2use serde::de::DeserializeOwned;
3use std::time::Instant;
4use tokio::time::sleep;
5
6use lago_types::{error::LagoError, error::Result};
7
8use crate::{Config, RetryMode};
9
10/// The main client for interacting with the Lago API
11///
12/// This client handles HTTP requests, authentication, retries, and error handling
13/// when communicating with the Lago billing API.
14#[derive(Clone)]
15pub struct LagoClient {
16    pub(crate) config: Config,
17    http_client: HttpClient,
18}
19
20impl LagoClient {
21    /// Creates a new Lago client with the provided configuration
22    ///
23    /// # Arguments
24    /// * `config` - The configuration settings for the client
25    ///
26    /// # Returns
27    /// A new instance of `LagoClient`
28    pub fn new(config: Config) -> Self {
29        let http_client = HttpClient::builder()
30            .timeout(config.timeout())
31            .user_agent(config.user_agent())
32            .build()
33            .expect("Failed to create HTTP client");
34
35        Self {
36            config,
37            http_client,
38        }
39    }
40
41    /// Creates a new Lago client using default configuration from environment variables
42    ///
43    /// This method will use default settings and attempt to load credentials
44    /// from environment variables.
45    ///
46    /// # Returns
47    /// A `Result` containing a new `LagoClient` instance or an error
48    pub fn from_env() -> Result<Self> {
49        let config = Config::default();
50        Ok(Self::new(config))
51    }
52
53    /// Makes an HTTP request to the Lago API with automatic retry logic
54    ///
55    /// This method handles authentication, request serialization, response deserialization,
56    /// error handling, and automatic retries based on the configured retry policy.
57    ///
58    /// # Arguments
59    /// * `method` - The HTTP method (GET, POST, PUT, DELETE)
60    /// * `url` - The full URL to make the request to
61    /// * `body` - Optional request body that will be serialized as JSON
62    ///
63    /// # Returns
64    /// A `Result` containing the deserialized response or an error
65    pub(crate) async fn make_request<T, B>(
66        &self,
67        method: &str,
68        url: &str,
69        body: Option<&B>,
70    ) -> Result<T>
71    where
72        T: DeserializeOwned,
73        B: serde::Serialize,
74    {
75        let credentials = self.config.credentials()?;
76        let mut attempt = 0;
77
78        loop {
79            let _start_time = Instant::now();
80
81            let mut request_builder = match method {
82                "GET" => self.http_client.get(url),
83                "POST" => self.http_client.post(url),
84                "PUT" => self.http_client.put(url),
85                "DELETE" => self.http_client.delete(url),
86                _ => {
87                    return Err(LagoError::Configuration(format!(
88                        "Unsupported method: {method}"
89                    )));
90                }
91            };
92
93            request_builder = request_builder.bearer_auth(credentials.api_key());
94
95            if let Some(body) = body {
96                request_builder = request_builder.json(body);
97            }
98
99            let response = match request_builder.send().await {
100                Ok(response) => response,
101                Err(e) => {
102                    if attempt >= self.config.retry_config().max_attempts {
103                        return Err(LagoError::Http(e));
104                    }
105
106                    attempt += 1;
107                    let delay = self.config.retry_config().delay_for_attempt(attempt);
108                    sleep(delay).await;
109                    continue;
110                }
111            };
112
113            match self.handle_response(response).await {
114                Ok(result) => return Ok(result),
115                Err(e) => {
116                    if !self.should_retry(&e, attempt) {
117                        return Err(e);
118                    }
119
120                    attempt += 1;
121                    let delay = self.config.retry_config().delay_for_attempt(attempt);
122                    sleep(delay).await;
123                    continue;
124                }
125            }
126        }
127    }
128
129    /// Processes the HTTP response and converts it to the expected type
130    ///
131    /// This method handles different HTTP status codes and converts them to appropriate
132    /// error types for the client to handle.
133    ///
134    /// # Arguments
135    /// * `response` - The HTTP response from the request
136    ///
137    /// # Returns
138    /// A `Result` containing the deserialized response data or an error
139    async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
140        let status = response.status();
141
142        if status.is_success() {
143            let text = response.text().await.map_err(LagoError::Http)?;
144            // Handle empty responses (e.g., 200 OK with no body)
145            if text.is_empty() {
146                return serde_json::from_str("{}").map_err(LagoError::Serialization);
147            }
148            serde_json::from_str(&text).map_err(LagoError::Serialization)
149        } else {
150            let error_text = response
151                .text()
152                .await
153                .unwrap_or_else(|_| "Unknown error".to_string());
154
155            match status.as_u16() {
156                401 => Err(LagoError::Unauthorized),
157                404 => Err(LagoError::Api {
158                    status: status.as_u16(),
159                    message: error_text,
160                }),
161                429 => Err(LagoError::RateLimit),
162                _ => Err(LagoError::Api {
163                    status: status.as_u16(),
164                    message: error_text,
165                }),
166            }
167        }
168    }
169
170    /// Determines whether a request should be retried based on the error type and attempt count
171    ///
172    /// This method implements the retry logic based on the configured retry policy.
173    /// It will retry on certain error types (HTTP errors, rate limits, server errors)
174    /// but not on client errors or authentication failures.
175    ///
176    /// # Arguments
177    /// * `error` - The error that occurred during the request
178    /// * `attempt` - The current attempt number (0-based)
179    ///
180    /// # Returns
181    /// `true` if the request should be retried, `false` otherwise
182    fn should_retry(&self, error: &LagoError, attempt: u32) -> bool {
183        if attempt >= self.config.retry_config().max_attempts {
184            return false;
185        }
186
187        if self.config.retry_config().mode == RetryMode::Off {
188            return false;
189        }
190
191        match error {
192            LagoError::Http(_) => true,
193            LagoError::RateLimit => true,
194            LagoError::Api { status, .. } => *status >= 500,
195            _ => false,
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::{Config, Credentials, Region, RetryConfig, RetryMode};
204    use lago_types::error::LagoError;
205    use mockito::Server;
206    use serde::{Deserialize, Serialize};
207    use serde_json::json;
208    use std::time::Duration;
209
210    #[derive(Debug, Deserialize, Serialize)]
211    struct TestResponse {
212        id: String,
213        name: String,
214    }
215
216    #[derive(Serialize)]
217    struct TestRequest {
218        name: String,
219    }
220
221    fn create_test_client(base_url: &str) -> LagoClient {
222        let config = Config::builder()
223            .credentials(Credentials::new("test-api-key".to_string()))
224            .region(Region::Custom(base_url.to_string()))
225            .timeout(Duration::from_secs(10))
226            .build();
227
228        LagoClient::new(config)
229    }
230
231    fn create_retry_client(base_url: &str, max_attempts: u32) -> LagoClient {
232        let retry_config = RetryConfig::builder()
233            .max_attempts(max_attempts)
234            .mode(RetryMode::Adaptive)
235            .build();
236
237        let config = Config::builder()
238            .credentials(Credentials::new("test-api-key".to_string()))
239            .region(Region::Custom(base_url.to_string()))
240            .retry_config(retry_config)
241            .timeout(Duration::from_secs(5))
242            .build();
243
244        LagoClient::new(config)
245    }
246
247    #[test]
248    fn test_new_client_creation() {
249        let config = Config::default();
250        let client = LagoClient::new(config.clone());
251
252        assert_eq!(client.config.timeout(), config.timeout());
253        assert_eq!(client.config.user_agent(), config.user_agent());
254    }
255
256    #[test]
257    fn test_from_env_client_creation() {
258        let result = LagoClient::from_env();
259        assert!(result.is_ok());
260    }
261
262    #[tokio::test]
263    async fn test_successful_get_request() {
264        let mut server = Server::new_async().await;
265        let mock = server
266            .mock("GET", "/test")
267            .with_status(200)
268            .with_header("content-type", "application/json")
269            .with_body(
270                json!({
271                    "id": "123",
272                    "name": "Test"
273                })
274                .to_string(),
275            )
276            .create_async()
277            .await;
278
279        let client = create_test_client(&server.url());
280        let url = format!("{}/test", server.url());
281
282        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
283
284        assert!(result.is_ok());
285
286        let response = result.unwrap();
287        assert_eq!(response.id, "123");
288        assert_eq!(response.name, "Test");
289
290        mock.assert_async().await;
291    }
292
293    #[tokio::test]
294    async fn test_successful_post_request() {
295        let mut server = Server::new_async().await;
296        let mock = server
297            .mock("POST", "/test")
298            .with_status(201)
299            .with_header("content-type", "application/json")
300            .match_body(mockito::Matcher::Json(json!({
301                "name": "New Item"
302            })))
303            .with_body(
304                json!({
305                    "id": "456",
306                    "name": "New Item"
307
308                })
309                .to_string(),
310            )
311            .create_async()
312            .await;
313
314        let client = create_test_client(&server.url());
315        let url = format!("{}/test", server.url());
316        let request = TestRequest {
317            name: "New Item".to_string(),
318        };
319
320        let result: Result<TestResponse> = client.make_request("POST", &url, Some(&request)).await;
321
322        assert!(result.is_ok());
323
324        let response = result.unwrap();
325        assert_eq!(response.id, "456");
326        assert_eq!(response.name, "New Item");
327
328        mock.assert_async().await;
329    }
330
331    #[tokio::test]
332    async fn test_authentication_header() {
333        let mut server = Server::new_async().await;
334        let mock = server
335            .mock("GET", "/test")
336            .match_header("Authorization", "Bearer test-api-key")
337            .with_status(200)
338            .with_body(json!({"id": "123", "name": "Test"}).to_string())
339            .create_async()
340            .await;
341
342        let client = create_test_client(&server.url());
343        let url = format!("{}/test", server.url());
344
345        let _result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
346
347        mock.assert_async().await;
348    }
349
350    #[tokio::test]
351    async fn test_unsupported_method() {
352        let server = Server::new_async().await;
353        let client = create_test_client(&server.url());
354        let url = format!("{}/test", server.url());
355
356        let result: Result<TestResponse> = client.make_request("PATCH", &url, None::<&()>).await;
357
358        assert!(result.is_err());
359
360        match result.unwrap_err() {
361            LagoError::Configuration(msg) => {
362                assert!(msg.contains("Unsupported method: PATCH"));
363            }
364            _ => panic!("Expected Configuration error"),
365        }
366    }
367
368    #[tokio::test]
369    async fn test_unauthorized_error() {
370        let mut server = Server::new_async().await;
371        let mock = server
372            .mock("GET", "/test")
373            .with_status(401)
374            .with_body("Unauthorized")
375            .create_async()
376            .await;
377
378        let client = create_test_client(&server.url());
379        let url = format!("{}/test", server.url());
380
381        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
382
383        assert!(result.is_err());
384
385        match result.unwrap_err() {
386            LagoError::Unauthorized => {}
387            _ => panic!("Expected Unauthorized error"),
388        }
389
390        mock.assert_async().await;
391    }
392
393    #[tokio::test]
394    async fn test_not_found_error() {
395        let mut server = Server::new_async().await;
396        let mock = server
397            .mock("GET", "/test")
398            .with_status(404)
399            .with_body("Not Found")
400            .create_async()
401            .await;
402
403        let client = create_test_client(&server.url());
404        let url = format!("{}/test", server.url());
405
406        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
407
408        assert!(result.is_err());
409
410        match result.unwrap_err() {
411            LagoError::Api { status, message } => {
412                assert_eq!(status, 404);
413                assert_eq!(message, "Not Found");
414            }
415            _ => panic!("Expected Api Error"),
416        }
417
418        mock.assert_async().await;
419    }
420
421    #[tokio::test]
422    async fn test_rate_limit_error() {
423        let mut server = Server::new_async().await;
424        let mock = server
425            .mock("GET", "/test")
426            .with_status(429)
427            .with_body("Rate Limited")
428            .create_async()
429            .await;
430
431        let client = create_test_client(&server.url());
432        let url = format!("{}/test", server.url());
433
434        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
435
436        assert!(result.is_err());
437
438        match result.unwrap_err() {
439            LagoError::RateLimit => {}
440            _ => panic!("Expected RateLimit error"),
441        }
442
443        mock.assert_async().await;
444    }
445
446    #[tokio::test]
447    async fn test_server_error() {
448        let mut server = Server::new_async().await;
449        let mock = server
450            .mock("GET", "/test")
451            .with_status(500)
452            .with_body("Internal Server Error")
453            .create_async()
454            .await;
455
456        let client = create_test_client(&server.url());
457        let url = format!("{}/test", server.url());
458
459        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
460
461        assert!(result.is_err());
462
463        match result.unwrap_err() {
464            LagoError::Api { status, message } => {
465                assert_eq!(status, 500);
466                assert_eq!(message, "Internal Server Error");
467            }
468            _ => panic!("Expected Api Error"),
469        }
470
471        mock.assert_async().await;
472    }
473
474    #[tokio::test]
475    async fn test_json_deserialization_error() {
476        let mut server = Server::new_async().await;
477        let mock = server
478            .mock("GET", "/test")
479            .with_status(200)
480            .with_body("invalid json")
481            .create_async()
482            .await;
483
484        let client = create_test_client(&server.url());
485        let url = format!("{}/test", server.url());
486
487        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
488
489        assert!(result.is_err());
490
491        match result.unwrap_err() {
492            LagoError::Serialization(_) => {}
493            _ => panic!("Expected Serialization error"),
494        }
495
496        mock.assert_async().await;
497    }
498
499    #[tokio::test]
500    async fn test_retry_on_server_error() {
501        let mut server = Server::new_async().await;
502        let mock = server
503            .mock("GET", "/test")
504            .with_status(500)
505            .with_body("Server Error")
506            .expect(4)
507            .create_async()
508            .await;
509
510        let client = create_retry_client(&server.url(), 3);
511        let url = format!("{}/test", server.url());
512
513        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
514
515        assert!(result.is_err());
516        mock.assert_async().await;
517    }
518
519    #[tokio::test]
520    async fn test_retry_then_success() {
521        let mut server = Server::new_async().await;
522
523        let mock_fail = server
524            .mock("GET", "/test")
525            .with_status(500)
526            .with_body("Server Error")
527            .expect(2)
528            .create_async()
529            .await;
530
531        let mock_success = server
532            .mock("GET", "/test")
533            .with_status(200)
534            .with_body(json!({"id": "123", "name": "Success"}).to_string())
535            .expect(1)
536            .create_async()
537            .await;
538
539        let client = create_retry_client(&server.url(), 5);
540        let url = format!("{}/test", server.url());
541
542        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
543
544        assert!(result.is_ok());
545
546        let response = result.unwrap();
547        assert_eq!(response.id, "123");
548        assert_eq!(response.name, "Success");
549
550        mock_fail.assert_async().await;
551        mock_success.assert_async().await;
552    }
553
554    #[tokio::test]
555    async fn test_no_retry_on_client_error() {
556        let mut server = Server::new_async().await;
557        let mock = server
558            .mock("GET", "/test")
559            .with_status(400)
560            .with_body("Bad Request")
561            .expect(1)
562            .create_async()
563            .await;
564
565        let client = create_retry_client(&server.url(), 3);
566        let url = format!("{}/test", server.url());
567
568        let result: Result<TestResponse> = client.make_request("GET", &url, None::<&()>).await;
569
570        assert!(result.is_err());
571        mock.assert_async().await;
572    }
573
574    #[tokio::test]
575    async fn test_should_retry_logic() {
576        let client = create_retry_client("http://localhost:8080", 3);
577
578        let rate_limit_error = LagoError::RateLimit;
579        assert!(client.should_retry(&rate_limit_error, 1));
580
581        let server_error = LagoError::Api {
582            status: 500,
583            message: "Server Error".to_string(),
584        };
585        assert!(client.should_retry(&server_error, 1));
586
587        let client_error = LagoError::Api {
588            status: 400,
589            message: "Bad Request".to_string(),
590        };
591        assert!(!client.should_retry(&client_error, 1));
592
593        assert!(!client.should_retry(&server_error, 3));
594
595        let auth_error = LagoError::Unauthorized;
596        assert!(!client.should_retry(&auth_error, 1));
597
598        let client_no_retry = create_test_client("http://localhost:8080");
599        assert!(!client_no_retry.should_retry(&server_error, 1));
600    }
601
602    #[tokio::test]
603    async fn test_timeout_handling() {
604        // Test timeout by using an unreachable address that will cause a timeout
605        let config = Config::builder()
606            .credentials(Credentials::new("test-api-key".to_string()))
607            .region(Region::Custom("http://10.255.255.1:80".to_string()))
608            .timeout(Duration::from_millis(100))
609            .build();
610
611        let client = LagoClient::new(config);
612        let url = "http://10.255.255.1:80/test";
613
614        let result: Result<TestResponse> = client.make_request("GET", url, None::<&()>).await;
615
616        assert!(result.is_err());
617
618        match result.unwrap_err() {
619            LagoError::Http(_) => {}
620            other => panic!("Expected HTTP error, got: {other:?}"),
621        }
622    }
623
624    #[tokio::test]
625    async fn test_empty_response_body() {
626        // Test that empty response bodies are handled correctly (e.g., retry_payment returns 200 with no body)
627        #[derive(Debug, Default, Deserialize)]
628        struct EmptyResponse {}
629
630        let mut server = Server::new_async().await;
631        let mock = server
632            .mock("POST", "/test")
633            .with_status(200)
634            .with_body("")
635            .create_async()
636            .await;
637
638        let client = create_test_client(&server.url());
639        let url = format!("{}/test", server.url());
640
641        let result: Result<EmptyResponse> = client.make_request("POST", &url, None::<&()>).await;
642
643        assert!(result.is_ok());
644        mock.assert_async().await;
645    }
646}