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}