From c1d98392afe01e7c7930b46e8d42f2587a52f83c Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 9 Aug 2026 11:26:52 +0530 Subject: [PATCH 1/3] Release 0.3.0: capture error-response body; Duration timeout; non_exhaustive errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes to the constructor signature and the generated error enum. Added: - The `Http` error variant now carries the response body (`Http { status, reason, body }`), so a non-2xx response no longer discards the server's explanation. `Display` includes the body when present. Changed (breaking): - `new` / `with_client` take the timeout as `impl Into>` instead of `Option` milliseconds — pass a `Duration` or `None` (5s default). Removes the `Some(30)`-means-30ms footgun. - The generated error enum is now `#[non_exhaustive]` and `Http` gained a `body` field. Docs, examples & tests: - New `error_handling.rs` example showing how to read a failed request's body. - All examples, the README, and the crate doctest updated to the `Duration` API and the error-body pattern; CHANGELOG entry for 0.3.0. - Added `test_http_error_captures_body`; all timeout call sites moved to `Duration`. --- CHANGELOG.md | 20 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 32 +++++++-- examples/advanced.rs | 5 +- examples/basic.rs | 7 +- examples/error_handling.rs | 60 +++++++++++++++++ examples/mocking.rs | 2 +- examples/multiple_path_params.rs | 3 +- examples/params.rs | 3 +- src/expanders/error.rs | 13 +++- src/expanders/method.rs | 4 ++ src/expanders/mod.rs | 13 +++- src/lib.rs | 3 +- tests/provider_tests.rs | 108 ++++++++++++++++++++++++------- 15 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 examples/error_handling.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ea3c7..3feb060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] + +This release contains breaking changes to the constructor signature and the generated +error enum. + +### Added + +- **The `Http` error variant now carries the response body.** A non-2xx response keeps the + raw payload the server returned (`Http { status, reason, body }`), so the reason a request + was rejected is no longer discarded. `Display` includes the body when present. + +### Changed + +- **Breaking:** `new` and `with_client` now take the timeout as + `impl Into>` instead of `Option` milliseconds. Pass a + `Duration` (e.g. `Duration::from_secs(5)`) or `None` for the 5-second default. This removes + the footgun where `Some(30)` meant 30 **milliseconds**, not 30 seconds. +- **Breaking:** the generated error enum is now `#[non_exhaustive]` and the `Http` variant has + a new `body` field. Exhaustive `match`es on it (from another crate) need a `_` arm. + ## [0.2.0] ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 1bdff3a..5961e6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,7 +41,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "beckon" -version = "0.2.0" +version = "0.3.0" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 325b1e6..1199d5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beckon" -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = ["Azeem Shaik "] description = "Generate type-safe, async HTTP clients from endpoint definitions — a Rust proc macro." diff --git a/README.md b/README.md index 3e33370..892aff6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ cargo add tokio --features full ```rust use beckon::beckon; use serde::{Deserialize, Serialize}; +use std::time::Duration; #[derive(Serialize, Deserialize)] struct User { @@ -69,7 +70,7 @@ beckon!( async fn main() -> Result<(), Box> { let client = UserApi::new( reqwest::Url::parse("https://api.example.com")?, - Some(5000), + Duration::from_secs(5), ); let users = client.get_users().await?; @@ -103,15 +104,15 @@ Add automatic authentication to every request. Three strategies are supported: ```rust // Bearer token — injects `Authorization: Bearer ` beckon!(GithubApi, auth: Bearer, { /* ... */ }); -let client = GithubApi::new(url, "ghp_xxxx", Some(5000)); +let client = GithubApi::new(url, "ghp_xxxx", Duration::from_secs(5)); // Basic auth — injects `Authorization: Basic ` beckon!(DbApi, auth: Basic, { /* ... */ }); -let client = DbApi::new(url, "admin", "secret", Some(5000)); +let client = DbApi::new(url, "admin", "secret", Duration::from_secs(5)); // API key — injects a custom header beckon!(StripeApi, auth: ApiKey("X-Api-Key"), { /* ... */ }); -let client = StripeApi::new(url, "sk_live_xxxx", Some(5000)); +let client = StripeApi::new(url, "sk_live_xxxx", Duration::from_secs(5)); ``` Omitting `auth` keeps the plain `new(url, timeout)` constructor. Auth composes with every @@ -151,11 +152,31 @@ Per-endpoint `retries` overrides the global value. Omitting `retries` entirely m - `UserApi::with_client(url, client, timeout)` — supply your own `reqwest::Client` to share a connection pool, TLS config, proxy, or default headers. +`timeout` accepts a `std::time::Duration`, or `None` for the 5-second default. + ```rust let http = reqwest::Client::builder().user_agent("my-app/1.0").build()?; -let client = UserApi::with_client(url, http, Some(5000)); +let client = UserApi::with_client(url, http, Duration::from_secs(5)); ``` +## Errors + +Every method returns `Result`. On a non-2xx response you get the +`Http` variant, which carries the server's response body so you can see *why* a request +was rejected: + +```rust +match client.get_users_by_id(&UserPath { id: 999 }).await { + Ok(user) => { /* ... */ } + Err(UserApiError::Http { status, reason, body }) => { + eprintln!("HTTP {status} {reason}: {body}"); // body = the server's error payload + } + Err(other) => eprintln!("{other}"), +} +``` + +The enum is `#[non_exhaustive]`, so matching downstream should keep a `_` arm. + ## Generated Code For a client named `UserApi`, the macro generates: @@ -174,6 +195,7 @@ See the [`examples/`](examples/) directory: - [`advanced.rs`](examples/advanced.rs) — all features - [`mocking.rs`](examples/mocking.rs) — testing with the generated trait - [`multiple_path_params.rs`](examples/multiple_path_params.rs) — nested resources +- [`error_handling.rs`](examples/error_handling.rs) — inspecting a failed request's body ## License diff --git a/examples/advanced.rs b/examples/advanced.rs index 92e9c87..9c9e596 100644 --- a/examples/advanced.rs +++ b/examples/advanced.rs @@ -12,6 +12,7 @@ use beckon::beckon; use reqwest::{header::HeaderMap, Url}; use serde::{Deserialize, Serialize}; +use std::time::Duration; // Response types #[derive(Deserialize, Debug)] @@ -141,11 +142,11 @@ async fn main() -> Result<(), Box> { .build()?; let base_url = Url::parse("https://api.example.com")?; - let client = ApiClient::with_client(base_url, http.clone(), Some(5000)); + let client = ApiClient::with_client(base_url, http.clone(), Duration::from_secs(5)); // A client for a different service reuses the very same pool and TLS state. let analytics_url = Url::parse("https://analytics.example.com")?; - let _analytics = ApiClient::with_client(analytics_url, http, Some(5000)); + let _analytics = ApiClient::with_client(analytics_url, http, Duration::from_secs(5)); // Basic GET request let users = client.get_users().await?; diff --git a/examples/basic.rs b/examples/basic.rs index 1ae0938..af1c863 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -6,6 +6,7 @@ use beckon::beckon; use reqwest::Url; use serde::Deserialize; +use std::time::Duration; // Define your response types #[derive(Deserialize, Debug)] @@ -42,14 +43,16 @@ beckon!( #[tokio::main] async fn main() -> Result<(), Box> { let base_url = Url::parse("https://api.example.com")?; - let client = ApiClient::new(base_url, Some(5000)); + // `timeout` takes a `Duration` (or `None` for the 5s default) — no ambiguous + // millisecond integers. + let client = ApiClient::new(base_url, Duration::from_secs(5)); // Want to configure the underlying HTTP client — a shared connection pool, // TLS, proxy, or default headers? Build your own `reqwest::Client` and pass // it in with `with_client` instead (see `advanced.rs`): // // let http = reqwest::Client::builder().user_agent("my-app/1.0").build()?; - // let client = ApiClient::with_client(base_url, http, Some(5000)); + // let client = ApiClient::with_client(base_url, http, Duration::from_secs(5)); // Use the auto-generated methods let users = client.get_users().await?; diff --git a/examples/error_handling.rs b/examples/error_handling.rs new file mode 100644 index 0000000..c9010b9 --- /dev/null +++ b/examples/error_handling.rs @@ -0,0 +1,60 @@ +//! Example demonstrating how to inspect a failed request. +//! +//! On any non-2xx response, the generated error's `Http` variant carries the raw +//! response body the server returned — usually a JSON error object — so you can see +//! *why* a request was rejected, not just the status code. + +use beckon::beckon; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct User { + id: u32, + name: String, +} + +#[derive(Serialize)] +pub struct UserPath { + id: u32, +} + +beckon!( + ApiClient, + { + { + path: "/users/{id}", + method: GET, + path_params: UserPath, + res: User, + }, + } +); + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = ApiClient::new( + reqwest::Url::parse("https://api.example.com")?, + Duration::from_secs(5), + ); + + match client.get_users_by_id(&UserPath { id: 999 }).await { + Ok(user) => println!("got user: {user:?}"), + + // The server explained the failure in the response body — now it's yours to + // read, log, or parse, instead of being discarded behind a bare status code. + Err(ApiClientError::Http { + status, + reason, + body, + }) => { + eprintln!("request failed: HTTP {status} {reason}"); + eprintln!("server said: {body}"); // e.g. {"error":"user not found"} + } + + Err(other) => eprintln!("transport or decode error: {other}"), + } + + Ok(()) +} diff --git a/examples/mocking.rs b/examples/mocking.rs index cfd3b79..9b39e91 100644 --- a/examples/mocking.rs +++ b/examples/mocking.rs @@ -70,7 +70,7 @@ async fn main() -> Result<(), Box> { // The same function works with the real client too: // let base_url = reqwest::Url::parse("https://api.example.com")?; - // let real_client = ApiClient::new(base_url, Some(5000)); + // let real_client = ApiClient::new(base_url, std::time::Duration::from_secs(5)); // let name = get_user_name(&real_client, 42).await?; Ok(()) diff --git a/examples/multiple_path_params.rs b/examples/multiple_path_params.rs index c502d7b..a152d9f 100644 --- a/examples/multiple_path_params.rs +++ b/examples/multiple_path_params.rs @@ -6,6 +6,7 @@ use beckon::beckon; use reqwest::Url; use serde::{Deserialize, Serialize}; +use std::time::Duration; #[derive(Deserialize, Debug)] #[allow(dead_code)] @@ -55,7 +56,7 @@ pub struct CommentReplyPathParams { #[tokio::main] async fn main() -> Result<(), Box> { let base_url = Url::parse("https://api.example.com")?; - let client = ApiClient::new(base_url, Some(5000)); + let client = ApiClient::new(base_url, Duration::from_secs(5)); // Use the generated method with multiple path parameters // Function name: get_users_posts_by_user_id_and_post_id diff --git a/examples/params.rs b/examples/params.rs index 06a58f7..014571a 100644 --- a/examples/params.rs +++ b/examples/params.rs @@ -6,6 +6,7 @@ use beckon::beckon; use reqwest::Url; use serde::{Deserialize, Serialize}; +use std::time::Duration; // Response types #[derive(Deserialize, Debug)] @@ -78,7 +79,7 @@ pub struct Post { #[tokio::main] async fn main() -> Result<(), Box> { let base_url = Url::parse("https://api.example.com")?; - let client = ApiClient::new(base_url, Some(5000)); + let client = ApiClient::new(base_url, Duration::from_secs(5)); // Use path parameters - the `{id}` in the path will be replaced with the value let user = client.get_users_by_id(&UserPathParams { id: 42 }).await?; diff --git a/src/expanders/error.rs b/src/expanders/error.rs index 2362cf6..f9494ba 100644 --- a/src/expanders/error.rs +++ b/src/expanders/error.rs @@ -16,10 +16,13 @@ impl<'a> ErrorExpander<'a> { quote! { #[derive(Debug)] + #[non_exhaustive] pub enum #error_name { UrlConstruction(String), Request(reqwest::Error), - Http { status: u16, reason: String }, + /// A non-2xx response. `body` holds the raw response payload the server + /// returned (often a JSON error object), or `""` if it couldn't be read. + Http { status: u16, reason: String, body: String }, Deserialization(String), } @@ -28,7 +31,13 @@ impl<'a> ErrorExpander<'a> { match self { Self::UrlConstruction(msg) => write!(f, "Failed to construct URL: {}", msg), Self::Request(err) => write!(f, "Request failed: {}", err), - Self::Http { status, reason } => write!(f, "HTTP {} {}", status, reason), + Self::Http { status, reason, body } => { + if body.is_empty() { + write!(f, "HTTP {} {}", status, reason) + } else { + write!(f, "HTTP {} {}: {}", status, reason, body) + } + } Self::Deserialization(msg) => write!(f, "Failed to deserialize: {}", msg), } } diff --git a/src/expanders/method.rs b/src/expanders/method.rs index f7f6917..80114cc 100644 --- a/src/expanders/method.rs +++ b/src/expanders/method.rs @@ -90,9 +90,11 @@ impl<'a> MethodExpander<'a> { .canonical_reason() .unwrap_or("Unknown") .to_string(); + let body = response.text().await.unwrap_or_default(); return Err(#error_name::Http { status: status.as_u16(), reason, + body, }); } break #deserialize; @@ -111,9 +113,11 @@ impl<'a> MethodExpander<'a> { .canonical_reason() .unwrap_or("Unknown") .to_string(); + let body = response.text().await.unwrap_or_default(); return Err(#error_name::Http { status: status.as_u16(), reason, + body, }); } #deserialize diff --git a/src/expanders/mod.rs b/src/expanders/mod.rs index 32cce58..a53d788 100644 --- a/src/expanders/mod.rs +++ b/src/expanders/mod.rs @@ -95,19 +95,26 @@ impl ApiClientExpander { } impl #struct_name { - pub fn new(url: reqwest::Url, #auth_params timeout: Option) -> Self { + /// `timeout` accepts a `std::time::Duration`, or `None` for the 5s default. + pub fn new( + url: reqwest::Url, + #auth_params + timeout: impl Into>, + ) -> Self { Self::with_client(url, #auth_args reqwest::Client::new(), timeout) } /// Build the client with a caller-supplied `reqwest::Client`, so a single /// connection pool, TLS config, proxy, or default headers can be shared. + /// + /// `timeout` accepts a `std::time::Duration`, or `None` for the 5s default. pub fn with_client( url: reqwest::Url, #auth_params client: reqwest::Client, - timeout: Option, + timeout: impl Into>, ) -> Self { - let timeout = std::time::Duration::from_millis(timeout.unwrap_or(5000)); + let timeout = timeout.into().unwrap_or(std::time::Duration::from_secs(5)); Self { url, client, timeout, #auth_inits } } diff --git a/src/lib.rs b/src/lib.rs index 67f89d9..b5b43cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ //! ``` //! use beckon::beckon; //! use serde::{Deserialize, Serialize}; +//! use std::time::Duration; //! //! #[derive(Serialize, Deserialize)] //! pub struct User { @@ -37,7 +38,7 @@ //! ); //! //! # async fn example() -> Result<(), Box> { -//! let client = UserApi::new(reqwest::Url::parse("https://api.example.com")?, Some(5000)); +//! let client = UserApi::new(reqwest::Url::parse("https://api.example.com")?, Duration::from_secs(5)); //! let _users = client.get_users().await?; //! let _user = client.get_users_by_id(&UserPath { id: 1 }).await?; //! # Ok(()) diff --git a/tests/provider_tests.rs b/tests/provider_tests.rs index 064cd35..f79999b 100644 --- a/tests/provider_tests.rs +++ b/tests/provider_tests.rs @@ -4,6 +4,7 @@ mod tests { use reqwest::{header::HeaderMap, Url}; use serde::{Deserialize, Serialize}; use std::str::FromStr; + use std::time::Duration; use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate}; // Define the client with various endpoint configurations @@ -85,7 +86,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider.get_users().await?; assert_eq!(result.value, "users"); @@ -103,7 +105,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider .get_users_by_id(&PathParams { id: "123".to_string(), @@ -126,7 +129,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider .get_search(&QueryParams { q: "test".to_string(), @@ -149,7 +153,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let mut headers = HeaderMap::new(); headers.insert("x-api-key", "secret".parse()?); @@ -170,7 +175,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider.get().await?; assert_eq!(result.value, "no-path"); @@ -188,7 +194,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider .post_users(&MyRequest { data: "test".to_string(), @@ -274,7 +281,8 @@ mod tests { .mount(&mock_server) .await; - let provider = PatchProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + PatchProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider .patch_users_by_id( &PathParams { @@ -300,7 +308,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider.get_users().await; assert!(result.is_err()); @@ -314,6 +323,35 @@ mod tests { Ok(()) } + // The server's error payload must survive on the `Http` variant instead of being + // discarded, so the caller can see *why* a request was rejected. + #[tokio::test] + async fn test_http_error_captures_body() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/users")) + .respond_with( + ResponseTemplate::new(422) + .set_body_string(r#"{"error":"email already taken","code":"user_exists"}"#), + ) + .mount(&mock_server) + .await; + + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); + let err = provider.get_users().await.unwrap_err(); + + match err { + HttpProviderError::Http { status, body, .. } => { + assert_eq!(status, 422); + assert!(body.contains("user_exists"), "body was: {body}"); + } + other => panic!("expected Http error, got {other:?}"), + } + Ok(()) + } + #[tokio::test] async fn test_multiple_path_params() -> Result<(), Box> { #[derive(Serialize, Deserialize)] @@ -343,7 +381,8 @@ mod tests { .mount(&mock_server) .await; - let provider = MultiParamProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + MultiParamProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider .get_users_posts_by_user_id_and_post_id(&MultiPathParams { user_id: "1".to_string(), @@ -390,7 +429,8 @@ mod tests { .mount(&mock_server) .await; - let provider = RetryProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + RetryProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider.get_flaky().await?; assert_eq!(result.value, "recovered"); @@ -420,7 +460,8 @@ mod tests { .mount(&mock_server) .await; - let provider = Retry4xxProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + Retry4xxProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider.get_not_found().await; assert!(result.is_err()); @@ -452,7 +493,8 @@ mod tests { .mount(&mock_server) .await; - let provider = RetryOverrideProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + RetryOverrideProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider.get_no_retry().await; assert!(result.is_err()); @@ -471,7 +513,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider.get_users().await?; assert_eq!(result.value, "compat"); @@ -510,7 +553,8 @@ mod tests { .mount(&mock_server) .await; - let provider = NoResponseProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + NoResponseProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); // Test DELETE without response let result: Result<(), _> = provider.delete_delete().await; @@ -556,7 +600,11 @@ mod tests { .mount(&mock_server) .await; - let provider = BearerApi::new(Url::from_str(&mock_server.uri())?, "my-token", Some(5000)); + let provider = BearerApi::new( + Url::from_str(&mock_server.uri())?, + "my-token", + Duration::from_secs(5), + ); let result = provider.get_protected().await?; assert_eq!(result.value, "bearer-ok"); @@ -591,7 +639,7 @@ mod tests { Url::from_str(&mock_server.uri())?, "user", "pass", - Some(5000), + Duration::from_secs(5), ); let result = provider.get_secure().await?; @@ -626,7 +674,7 @@ mod tests { let provider = ApiKeyApi::new( Url::from_str(&mock_server.uri())?, "sk_live_123", - Some(5000), + Duration::from_secs(5), ); let result = provider.get_data().await?; @@ -668,7 +716,11 @@ mod tests { .mount(&mock_server) .await; - let provider = AuthRetryApi::new(Url::from_str(&mock_server.uri())?, "tok", Some(5000)); + let provider = AuthRetryApi::new( + Url::from_str(&mock_server.uri())?, + "tok", + Duration::from_secs(5), + ); let result = provider.get_flaky_auth().await?; assert_eq!(result.value, "auth-retry-ok"); @@ -689,7 +741,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let result = provider .get_users_by_id(&PathParams { id: "1/2".to_string(), @@ -712,8 +765,11 @@ mod tests { .await; let client = reqwest::Client::new(); - let provider = - HttpProvider::with_client(Url::from_str(&mock_server.uri())?, client, Some(5000)); + let provider = HttpProvider::with_client( + Url::from_str(&mock_server.uri())?, + client, + Duration::from_secs(5), + ); let result = provider.get_users().await?; assert_eq!(result.value, "shared-client"); @@ -741,8 +797,11 @@ mod tests { .default_headers(default_headers) .build()?; - let provider = - HttpProvider::with_client(Url::from_str(&mock_server.uri())?, http, Some(5000)); + let provider = HttpProvider::with_client( + Url::from_str(&mock_server.uri())?, + http, + Duration::from_secs(5), + ); // Mock only matches when the injected client's default header is present. let result = provider.get_users().await?; @@ -764,7 +823,8 @@ mod tests { .mount(&mock_server) .await; - let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, Some(5000)); + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); let cloned = provider.clone(); let from_clone = cloned.get_users().await?; From a0d407fa852dd56b0feb754c9d5aa615fe4175a7 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 9 Aug 2026 11:39:36 +0530 Subject: [PATCH 2/3] Broaden error-body test coverage: empty, plain-text, and retry-path cases The initial change had only one error-body test (422 + JSON on the non-retry path). Add cases for an empty body, a non-JSON body, and the separate retry-path branch, plus Display-formatting assertions. --- src/expanders/error.rs | 2 - tests/provider_tests.rs | 96 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/expanders/error.rs b/src/expanders/error.rs index f9494ba..1ac0ac4 100644 --- a/src/expanders/error.rs +++ b/src/expanders/error.rs @@ -20,8 +20,6 @@ impl<'a> ErrorExpander<'a> { pub enum #error_name { UrlConstruction(String), Request(reqwest::Error), - /// A non-2xx response. `body` holds the raw response payload the server - /// returned (often a JSON error object), or `""` if it couldn't be read. Http { status: u16, reason: String, body: String }, Deserialization(String), } diff --git a/tests/provider_tests.rs b/tests/provider_tests.rs index f79999b..b9a47f2 100644 --- a/tests/provider_tests.rs +++ b/tests/provider_tests.rs @@ -352,6 +352,102 @@ mod tests { Ok(()) } + // A non-2xx with no body must leave `body` empty and keep `Display` clean (no + // trailing separator). + #[tokio::test] + async fn test_http_error_empty_body() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/users")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); + let err = provider.get_users().await.unwrap_err(); + + match &err { + HttpProviderError::Http { status, body, .. } => { + assert_eq!(*status, 404); + assert_eq!(body, ""); + } + other => panic!("expected Http error, got {other:?}"), + } + assert_eq!(format!("{err}"), "HTTP 404 Not Found"); + Ok(()) + } + + // A non-JSON error body is captured verbatim and surfaced through `Display`. + #[tokio::test] + async fn test_http_error_plain_text_body() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/users")) + .respond_with(ResponseTemplate::new(500).set_body_string("upstream exploded")) + .mount(&mock_server) + .await; + + let provider = + HttpProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); + let err = provider.get_users().await.unwrap_err(); + + match &err { + HttpProviderError::Http { status, body, .. } => { + assert_eq!(*status, 500); + assert_eq!(body, "upstream exploded"); + } + other => panic!("expected Http error, got {other:?}"), + } + assert_eq!( + format!("{err}"), + "HTTP 500 Internal Server Error: upstream exploded" + ); + Ok(()) + } + + // The retry path is a separate branch: after retries are exhausted on a 5xx, the + // error must still carry the body from the final response. + #[tokio::test] + async fn test_http_error_body_on_retry_path() -> Result<(), Box> { + beckon!( + RetryBodyProvider, + retries: 2, + { + { + path: "/always-500", + method: GET, + res: MyResponse, + }, + } + ); + + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/always-500")) + .respond_with( + ResponseTemplate::new(503).set_body_string(r#"{"error":"service unavailable"}"#), + ) + .mount(&mock_server) + .await; + + let provider = + RetryBodyProvider::new(Url::from_str(&mock_server.uri())?, Duration::from_secs(5)); + let err = provider.get_always_500().await.unwrap_err(); + + match err { + RetryBodyProviderError::Http { status, body, .. } => { + assert_eq!(status, 503); + assert!(body.contains("service unavailable"), "body was: {body}"); + } + other => panic!("expected Http error, got {other:?}"), + } + Ok(()) + } + #[tokio::test] async fn test_multiple_path_params() -> Result<(), Box> { #[derive(Serialize, Deserialize)] From 2f63a118dffa42571ef37bc82dc7eb9e8dd4dcf8 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 9 Aug 2026 11:43:41 +0530 Subject: [PATCH 3/3] Add test proving None selects the default timeout without a turbofish The Duration timeout signature is impl Into>; the README documents passing None for the 5s default, but every other test passes an explicit Duration. Lock in that None compiles and works end to end. --- tests/provider_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/provider_tests.rs b/tests/provider_tests.rs index b9a47f2..a434047 100644 --- a/tests/provider_tests.rs +++ b/tests/provider_tests.rs @@ -206,6 +206,27 @@ mod tests { Ok(()) } + // The docs promise `None` selects the default timeout — this must compile without a + // turbofish (`None::`) and work end to end. + #[tokio::test] + async fn test_new_with_none_timeout() -> Result<(), Box> { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(wiremock::matchers::path("/users")) + .respond_with( + ResponseTemplate::new(200).set_body_json(create_success_response("default")), + ) + .mount(&mock_server) + .await; + + let provider = HttpProvider::new(Url::from_str(&mock_server.uri())?, None); + let result = provider.get_users().await?; + + assert_eq!(result.value, "default"); + Ok(()) + } + // Trait-based mock client test #[tokio::test] async fn test_trait_mock_provider() -> Result<(), Box> {