Skip to main content

polyoxide_core/
lib.rs

1//! # polyoxide-core
2//!
3//! Core utilities and shared types for the polyoxide venue clients.
4//!
5//! This crate provides the venue-neutral machinery every venue crate builds
6//! its HTTP clients on, and names no venue:
7//! - Shared error types and error handling
8//! - HTTP client configuration
9//! - The transport seam, [`HttpTransport`], with the [`Response`] it answers
10//!   with and the [`TransportError`] it fails with, so the send loop names no
11//!   HTTP library
12//! - Request builder utilities
13//! - One send loop, [`HttpClient::send`], with the hooks a venue supplies
14//!   ([`Throttle`], [`RetryPolicy`], [`Authenticator`]), and one health
15//!   ping on it, [`HttpClient::health`]
16//! - Per-endpoint window quotas ([`WindowQuotaTable`], which builds a
17//!   [`RateLimiter`]), token-cost buckets ([`CapacityBucket`]), the
18//!   [`Hold`] their layers share, and the retry schedule ([`RetryConfig`])
19//! - Optional OS keychain credential storage (behind the `keychain` feature)
20//!
21//! ## HTTP Client
22//!
23//! Use [`HttpClientBuilder`] to create configured HTTP clients:
24//!
25//! ```
26//! use polyoxide_core::HttpClientBuilder;
27//!
28//! let client = HttpClientBuilder::new("https://api.example.com")
29//!     .timeout_ms(60_000)
30//!     .build()
31//!     .unwrap();
32//! ```
33//!
34//! ## Error Handling
35//!
36//! An unsuccessful response is [`ApiError::Response`], carrying an
37//! [`ErrorResponse`]: its status, headers, body, message and `Retry-After`.
38//! A venue's error type wraps [`ApiError`], and its `From<ApiError>` is its
39//! one decode, so a `?` on core's error anywhere reads the venue's body.
40
41// Compile the crate README's `rust` code fences as doctests so broken examples
42// fail CI. `#[cfg(doctest)]` keeps this out of normal builds and `cargo doc`.
43#[cfg(doctest)]
44#[doc = include_str!("../README.md")]
45struct ReadmeDoctests;
46
47#[macro_use]
48pub mod macros;
49
50pub mod capacity;
51pub mod client;
52pub mod config;
53pub mod error;
54mod fetch;
55pub mod health;
56pub mod hold;
57pub mod hooks;
58mod query;
59pub mod rate_limit;
60pub mod request;
61pub mod response;
62pub mod send;
63pub mod transport;
64
65#[cfg(feature = "keychain")]
66pub mod keychain;
67
68#[cfg(test)]
69mod macro_tests;
70
71/// Maximum number of characters to include in log messages containing response bodies.
72const LOG_BODY_MAX_LEN: usize = 512;
73
74/// Truncate a string for safe inclusion in log output.
75///
76/// Returns the original string if it fits within `LOG_BODY_MAX_LEN`,
77/// otherwise truncates at a UTF-8 boundary and appends `... [truncated]`.
78pub fn truncate_for_log(s: &str) -> std::borrow::Cow<'_, str> {
79    if s.len() <= LOG_BODY_MAX_LEN {
80        std::borrow::Cow::Borrowed(s)
81    } else {
82        let truncated = &s[..s.floor_char_boundary(LOG_BODY_MAX_LEN)];
83        std::borrow::Cow::Owned(format!("{}... [truncated]", truncated))
84    }
85}
86
87/// The request vocabulary core's HTTP client speaks: `Method`, `StatusCode`,
88/// `HeaderMap`, `HeaderValue` and `header::*`.
89///
90/// Venue code, tests and examples name these through this path, never through
91/// reqwest. They are the very types reqwest re-exports, so moving a path here
92/// changes no type.
93pub use http;
94
95/// The runtime seam, for the macros of this crate to name: a crate invoking
96/// `client_config_setters!` needs no `polyoxide-rt` dependency of its own.
97#[doc(hidden)]
98pub use polyoxide_rt as __rt;
99
100/// The HTTP client core sends with, re-exported whole.
101///
102/// Venue crates name its client types (`Client`, `Response`, `RequestBuilder`)
103/// through this path and never depend on reqwest themselves, so core alone
104/// decides its features and every client sends the same headers (AD-18). CI's
105/// dependency fence fails when any other member declares it.
106#[cfg(not(target_arch = "wasm32"))]
107pub use reqwest;
108
109pub use capacity::CapacityBucket;
110pub use client::{
111    retry_after_header, HttpClient, HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
112};
113pub use config::ClientConfig;
114pub use error::{ApiError, ErrorResponse};
115#[cfg(target_arch = "wasm32")]
116pub use fetch::FetchTransport;
117pub use health::Pong;
118pub use hold::Hold;
119pub use hooks::{
120    AttemptInfo, Authenticator, Charge, Cost, Decision, DefaultRetryPolicy, DynAuthenticator,
121    DynRetryPolicy, DynThrottle, LayerCharge, LayerId, NoThrottle, Outcome, Refused, RequestMeta,
122    RequestParts, ResponseMeta, RetryPolicy, Throttle,
123};
124pub use query::csv;
125pub use rate_limit::{
126    BucketId, EffectiveQuota, Matching, QuotaRow, RateLimiter, RetryConfig, WindowQuotaTable,
127};
128pub use request::{QueryBuilder, Request, RequestError};
129pub use response::Response;
130pub use send::decode_json;
131#[cfg(not(target_arch = "wasm32"))]
132pub use transport::ReqwestTransport;
133pub use transport::{HttpRequest, HttpTransport, TransportError, TransportKind};
134
135#[cfg(feature = "keychain")]
136pub use keychain::KeychainError;
137
138// Every public error type implements `Classify`; one without it fails the
139// build here. `.github/scripts/tests/test_classify_coverage.py` fails when a
140// public error type is missing from this list.
141const _: fn() = || {
142    fn is<T: polyoxide_venue::Classify>() {}
143    is::<ApiError>();
144    is::<Refused>();
145    is::<TransportError>();
146    #[cfg(feature = "keychain")]
147    is::<KeychainError>();
148};
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    /// `polyoxide_core::http` names the types reqwest sends with: each value
155    /// below is assigned to the reqwest-typed binding with no conversion, so a
156    /// re-export of any other version of the crate would not compile. Every
157    /// path starts at the crate root, which the extern prelude does not serve,
158    /// so deleting either `pub use` fails to compile here.
159    #[test]
160    fn the_http_vocabulary_is_the_type_reqwest_sends() {
161        let method: crate::reqwest::Method = crate::http::Method::POST;
162        let status: crate::reqwest::StatusCode = crate::http::StatusCode::TOO_MANY_REQUESTS;
163        let mut headers: crate::reqwest::header::HeaderMap = crate::http::HeaderMap::new();
164        headers.insert(
165            crate::http::header::RETRY_AFTER,
166            crate::http::HeaderValue::from_static("3"),
167        );
168
169        assert_eq!(method, crate::reqwest::Method::POST);
170        assert_eq!(status.as_u16(), 429);
171        assert_eq!(
172            headers[crate::reqwest::header::RETRY_AFTER]
173                .to_str()
174                .unwrap(),
175            "3"
176        );
177    }
178
179    #[test]
180    fn test_truncate_for_log_short_string_unchanged() {
181        let short = "hello world";
182        let result = truncate_for_log(short);
183        assert_eq!(result.as_ref(), short);
184    }
185
186    #[test]
187    fn test_truncate_for_log_exact_limit_unchanged() {
188        let exact = "a".repeat(LOG_BODY_MAX_LEN);
189        let result = truncate_for_log(&exact);
190        assert_eq!(result.as_ref(), exact.as_str());
191    }
192
193    #[test]
194    fn test_truncate_for_log_over_limit_truncated() {
195        let long = "x".repeat(LOG_BODY_MAX_LEN + 100);
196        let result = truncate_for_log(&long);
197        assert!(result.ends_with("... [truncated]"));
198        assert!(result.len() < long.len());
199    }
200
201    #[test]
202    fn test_truncate_for_log_multibyte_char_boundary() {
203        // Create a string where the 512th byte falls inside a multi-byte char
204        let mut s = "a".repeat(LOG_BODY_MAX_LEN - 1);
205        s.push('\u{1F600}'); // 4-byte emoji at position 511-514
206        s.push_str("overflow");
207        let result = truncate_for_log(&s);
208        assert!(result.ends_with("... [truncated]"));
209        // Should not panic or produce invalid UTF-8
210        assert!(result.is_char_boundary(0));
211    }
212}