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}