Skip to main content

polyoxide_core/
lib.rs

1//! # polyoxide-core
2//!
3//! Core utilities and shared types for Polyoxide Polymarket API clients.
4//!
5//! This crate provides common functionality used across `polyoxide-clob`, `polyoxide-gamma`, `polyoxide-data`, and `polyoxide-relay`:
6//! - Shared error types and error handling
7//! - HTTP client configuration
8//! - Request builder utilities
9//! - HMAC API-credential signing ([`Signer`])
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 auth;
48pub mod capacity;
49pub mod client;
50pub mod config;
51pub mod error;
52pub mod health;
53pub mod hold;
54pub mod hooks;
55pub mod polymarket;
56mod query;
57pub mod rate_limit;
58pub mod request;
59pub mod send;
60pub mod session_signer;
61pub mod signer_limit;
62
63#[cfg(feature = "keychain")]
64pub mod keychain;
65
66#[cfg(test)]
67mod macro_tests;
68
69/// Maximum number of characters to include in log messages containing response bodies.
70const LOG_BODY_MAX_LEN: usize = 512;
71
72/// Truncate a string for safe inclusion in log output.
73///
74/// Returns the original string if it fits within `LOG_BODY_MAX_LEN`,
75/// otherwise truncates at a UTF-8 boundary and appends `... [truncated]`.
76pub fn truncate_for_log(s: &str) -> std::borrow::Cow<'_, str> {
77    if s.len() <= LOG_BODY_MAX_LEN {
78        std::borrow::Cow::Borrowed(s)
79    } else {
80        let truncated = &s[..s.floor_char_boundary(LOG_BODY_MAX_LEN)];
81        std::borrow::Cow::Owned(format!("{}... [truncated]", truncated))
82    }
83}
84
85/// The HTTP client core sends with, re-exported whole.
86///
87/// Venue crates name its types (`Response`, `StatusCode`, `HeaderMap`,
88/// `Method`) through this path and never depend on reqwest themselves, so
89/// core alone decides its features and every client sends the same headers
90/// (AD-18). CI's dependency fence fails when any other member declares it.
91pub use reqwest;
92
93pub use auth::{current_timestamp, Base64Format, Signer};
94pub use capacity::CapacityBucket;
95pub use client::{
96    retry_after_header, HttpClient, HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
97};
98pub use config::ClientConfig;
99pub use error::{ApiError, ErrorResponse};
100pub use health::Pong;
101pub use hold::Hold;
102pub use hooks::{
103    AttemptInfo, Authenticator, Charge, Cost, Decision, DefaultRetryPolicy, DynAuthenticator,
104    DynRetryPolicy, DynThrottle, LayerCharge, LayerId, NoThrottle, Outcome, Refused, RequestMeta,
105    RequestParts, ResponseMeta, RetryPolicy, Throttle,
106};
107pub use query::csv;
108pub use rate_limit::{
109    BucketId, EffectiveQuota, Matching, QuotaRow, RateLimiter, RetryConfig, WindowQuotaTable,
110};
111pub use request::{QueryBuilder, Request, RequestError};
112pub use send::decode_json;
113pub use session_signer::{DepositWalletRole, SessionSignerScope};
114pub use signer_limit::{
115    BurstCapacityExceeded, RateLimitStatus, SignerLimiter, Tier, TradingBucket, TradingRequest,
116};
117
118#[cfg(feature = "keychain")]
119pub use keychain::KeychainError;
120
121// Every public error type implements `Classify`; one without it fails the
122// build here. `.github/scripts/tests/test_classify_coverage.py` fails when a
123// public error type is missing from this list.
124const _: fn() = || {
125    fn is<T: polyoxide_venue::Classify>() {}
126    is::<ApiError>();
127    is::<BurstCapacityExceeded>();
128    is::<Refused>();
129    #[cfg(feature = "keychain")]
130    is::<KeychainError>();
131};
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_truncate_for_log_short_string_unchanged() {
139        let short = "hello world";
140        let result = truncate_for_log(short);
141        assert_eq!(result.as_ref(), short);
142    }
143
144    #[test]
145    fn test_truncate_for_log_exact_limit_unchanged() {
146        let exact = "a".repeat(LOG_BODY_MAX_LEN);
147        let result = truncate_for_log(&exact);
148        assert_eq!(result.as_ref(), exact.as_str());
149    }
150
151    #[test]
152    fn test_truncate_for_log_over_limit_truncated() {
153        let long = "x".repeat(LOG_BODY_MAX_LEN + 100);
154        let result = truncate_for_log(&long);
155        assert!(result.ends_with("... [truncated]"));
156        assert!(result.len() < long.len());
157    }
158
159    #[test]
160    fn test_truncate_for_log_multibyte_char_boundary() {
161        // Create a string where the 512th byte falls inside a multi-byte char
162        let mut s = "a".repeat(LOG_BODY_MAX_LEN - 1);
163        s.push('\u{1F600}'); // 4-byte emoji at position 511-514
164        s.push_str("overflow");
165        let result = truncate_for_log(&s);
166        assert!(result.ends_with("... [truncated]"));
167        // Should not panic or produce invalid UTF-8
168        assert!(result.is_char_boundary(0));
169    }
170}