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//! - Request builder utilities
10//! - One send loop, [`HttpClient::send`], with the hooks a venue supplies
11//!   ([`Throttle`], [`RetryPolicy`], [`Authenticator`]), and one health
12//!   ping on it, [`HttpClient::health`]
13//! - Per-endpoint window quotas ([`WindowQuotaTable`], which builds a
14//!   [`RateLimiter`]), token-cost buckets ([`CapacityBucket`]), the
15//!   [`Hold`] their layers share, and the retry schedule ([`RetryConfig`])
16//! - Optional OS keychain credential storage (behind the `keychain` feature)
17//!
18//! ## HTTP Client
19//!
20//! Use [`HttpClientBuilder`] to create configured HTTP clients:
21//!
22//! ```
23//! use polyoxide_core::HttpClientBuilder;
24//!
25//! let client = HttpClientBuilder::new("https://api.example.com")
26//!     .timeout_ms(60_000)
27//!     .build()
28//!     .unwrap();
29//! ```
30//!
31//! ## Error Handling
32//!
33//! An unsuccessful response is [`ApiError::Response`], carrying an
34//! [`ErrorResponse`]: its status, headers, body, message and `Retry-After`.
35//! A venue's error type wraps [`ApiError`], and its `From<ApiError>` is its
36//! one decode, so a `?` on core's error anywhere reads the venue's body.
37
38// Compile the crate README's `rust` code fences as doctests so broken examples
39// fail CI. `#[cfg(doctest)]` keeps this out of normal builds and `cargo doc`.
40#[cfg(doctest)]
41#[doc = include_str!("../README.md")]
42struct ReadmeDoctests;
43
44#[macro_use]
45pub mod macros;
46
47pub mod capacity;
48pub mod client;
49pub mod config;
50pub mod error;
51pub mod health;
52pub mod hold;
53pub mod hooks;
54mod query;
55pub mod rate_limit;
56pub mod request;
57pub mod send;
58
59#[cfg(feature = "keychain")]
60pub mod keychain;
61
62#[cfg(test)]
63mod macro_tests;
64
65/// Maximum number of characters to include in log messages containing response bodies.
66const LOG_BODY_MAX_LEN: usize = 512;
67
68/// Truncate a string for safe inclusion in log output.
69///
70/// Returns the original string if it fits within `LOG_BODY_MAX_LEN`,
71/// otherwise truncates at a UTF-8 boundary and appends `... [truncated]`.
72pub fn truncate_for_log(s: &str) -> std::borrow::Cow<'_, str> {
73    if s.len() <= LOG_BODY_MAX_LEN {
74        std::borrow::Cow::Borrowed(s)
75    } else {
76        let truncated = &s[..s.floor_char_boundary(LOG_BODY_MAX_LEN)];
77        std::borrow::Cow::Owned(format!("{}... [truncated]", truncated))
78    }
79}
80
81/// The HTTP client core sends with, re-exported whole.
82///
83/// Venue crates name its types (`Response`, `StatusCode`, `HeaderMap`,
84/// `Method`) through this path and never depend on reqwest themselves, so
85/// core alone decides its features and every client sends the same headers
86/// (AD-18). CI's dependency fence fails when any other member declares it.
87pub use reqwest;
88
89pub use capacity::CapacityBucket;
90pub use client::{
91    retry_after_header, HttpClient, HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
92};
93pub use config::ClientConfig;
94pub use error::{ApiError, ErrorResponse};
95pub use health::Pong;
96pub use hold::Hold;
97pub use hooks::{
98    AttemptInfo, Authenticator, Charge, Cost, Decision, DefaultRetryPolicy, DynAuthenticator,
99    DynRetryPolicy, DynThrottle, LayerCharge, LayerId, NoThrottle, Outcome, Refused, RequestMeta,
100    RequestParts, ResponseMeta, RetryPolicy, Throttle,
101};
102pub use query::csv;
103pub use rate_limit::{
104    BucketId, EffectiveQuota, Matching, QuotaRow, RateLimiter, RetryConfig, WindowQuotaTable,
105};
106pub use request::{QueryBuilder, Request, RequestError};
107pub use send::decode_json;
108
109#[cfg(feature = "keychain")]
110pub use keychain::KeychainError;
111
112// Every public error type implements `Classify`; one without it fails the
113// build here. `.github/scripts/tests/test_classify_coverage.py` fails when a
114// public error type is missing from this list.
115const _: fn() = || {
116    fn is<T: polyoxide_venue::Classify>() {}
117    is::<ApiError>();
118    is::<Refused>();
119    #[cfg(feature = "keychain")]
120    is::<KeychainError>();
121};
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_truncate_for_log_short_string_unchanged() {
129        let short = "hello world";
130        let result = truncate_for_log(short);
131        assert_eq!(result.as_ref(), short);
132    }
133
134    #[test]
135    fn test_truncate_for_log_exact_limit_unchanged() {
136        let exact = "a".repeat(LOG_BODY_MAX_LEN);
137        let result = truncate_for_log(&exact);
138        assert_eq!(result.as_ref(), exact.as_str());
139    }
140
141    #[test]
142    fn test_truncate_for_log_over_limit_truncated() {
143        let long = "x".repeat(LOG_BODY_MAX_LEN + 100);
144        let result = truncate_for_log(&long);
145        assert!(result.ends_with("... [truncated]"));
146        assert!(result.len() < long.len());
147    }
148
149    #[test]
150    fn test_truncate_for_log_multibyte_char_boundary() {
151        // Create a string where the 512th byte falls inside a multi-byte char
152        let mut s = "a".repeat(LOG_BODY_MAX_LEN - 1);
153        s.push('\u{1F600}'); // 4-byte emoji at position 511-514
154        s.push_str("overflow");
155        let result = truncate_for_log(&s);
156        assert!(result.ends_with("... [truncated]"));
157        // Should not panic or produce invalid UTF-8
158        assert!(result.is_char_boundary(0));
159    }
160}