specter/timeouts.rs
1//! Timeout configuration for HTTP requests.
2//!
3//! Provides granular timeout control matching production best practices from
4//! reqwest, curl, httpx, and aiohttp.
5//!
6//! # Timeout Types
7//!
8//! - **connect**: TCP + TLS/QUIC handshake timeout
9//! - **ttfb**: Time-to-first-byte (request sent → response headers received)
10//! - **read_idle**: Maximum time between received bytes (resets on each chunk)
11//! - **write_idle**: Maximum time between sent bytes (for uploads)
12//! - **total**: Absolute deadline for entire request lifecycle
13//! - **pool_acquire**: Time to wait for a pooled connection
14//!
15//! # Usage
16//!
17//! ```rust,ignore
18//! use specter::{Client, Timeouts};
19//! use std::time::Duration;
20//!
21//! // For normal API calls
22//! let client = Client::builder()
23//! .timeouts(Timeouts::api_defaults())
24//! .build()?;
25//!
26//! // For streaming (SSE, etc.)
27//! let client = Client::builder()
28//! .timeouts(Timeouts::streaming_defaults())
29//! .build()?;
30//!
31//! // Custom configuration
32//! let client = Client::builder()
33//! .connect_timeout(Duration::from_secs(5))
34//! .ttfb_timeout(Duration::from_secs(30))
35//! .read_timeout(Duration::from_secs(60))
36//! .build()?;
37//! ```
38
39use std::time::Duration;
40
41/// Timeout configuration for HTTP requests.
42///
43/// All timeouts are optional. When `None`, no timeout is applied for that phase.
44///
45/// # Timeout Semantics
46///
47/// - **connect**: Does NOT reset. Deadline for establishing transport connection.
48/// - **ttfb**: Does NOT reset. Deadline from request sent to headers received.
49/// - **read_idle**: RESETS on each chunk received. Detects hung streams.
50/// - **write_idle**: RESETS on each chunk sent. Detects hung uploads.
51/// - **total**: Does NOT reset. Absolute deadline for entire request.
52/// - **pool_acquire**: Does NOT reset. Time waiting for pooled connection.
53#[derive(Clone, Debug, Default)]
54pub struct Timeouts {
55 /// Timeout for establishing connection (DNS + TCP + TLS/QUIC handshake).
56 ///
57 /// Default: 10s for api_defaults(), 10s for streaming_defaults()
58 pub connect: Option<Duration>,
59
60 /// Time-to-first-byte timeout: time from request sent until response headers received.
61 ///
62 /// This is the "server responsiveness" timeout - detects servers that accept
63 /// connections but hang before responding.
64 ///
65 /// Default: 30s for api_defaults(), 30s for streaming_defaults()
66 pub ttfb: Option<Duration>,
67
68 /// Read idle timeout: maximum time waiting for next chunk of response body.
69 ///
70 /// **This timeout resets on each successful read.** It detects hung streams
71 /// without killing healthy long-running transfers.
72 ///
73 /// For SSE/streaming, this is typically your primary timeout mechanism.
74 ///
75 /// Default: 30s for api_defaults(), 120s for streaming_defaults()
76 pub read_idle: Option<Duration>,
77
78 /// Write idle timeout: maximum time waiting to send next chunk of request body.
79 ///
80 /// **This timeout resets on each successful write.** Useful for large uploads.
81 ///
82 /// Default: 30s for both presets
83 pub write_idle: Option<Duration>,
84
85 /// Total request deadline: absolute time limit for entire request lifecycle.
86 ///
87 /// **This timeout does NOT reset.** It caps connect + request + response.
88 ///
89 /// For streaming responses, you typically want this disabled (None) and
90 /// rely on read_idle instead.
91 ///
92 /// Default: 120s for api_defaults(), None for streaming_defaults()
93 pub total: Option<Duration>,
94
95 /// Pool acquire timeout: time waiting for an available pooled connection.
96 ///
97 /// Under high load, this prevents requests from queueing indefinitely.
98 ///
99 /// Default: 5s for both presets
100 pub pool_acquire: Option<Duration>,
101}
102
103impl Timeouts {
104 /// Create a new Timeouts with all timeouts set to None.
105 pub fn new() -> Self {
106 Self::default()
107 }
108
109 /// Sensible defaults for normal API calls.
110 ///
111 /// - connect: 10s
112 /// - ttfb: 30s
113 /// - read_idle: 30s
114 /// - write_idle: 30s
115 /// - total: 120s
116 /// - pool_acquire: 5s
117 pub fn api_defaults() -> Self {
118 Self {
119 connect: Some(Duration::from_secs(10)),
120 ttfb: Some(Duration::from_secs(30)),
121 read_idle: Some(Duration::from_secs(30)),
122 write_idle: Some(Duration::from_secs(30)),
123 total: Some(Duration::from_secs(120)),
124 pool_acquire: Some(Duration::from_secs(5)),
125 }
126 }
127
128 /// Sensible defaults for streaming responses (SSE, chunked downloads, etc.).
129 ///
130 /// Key differences from api_defaults():
131 /// - total: None (streams can run indefinitely)
132 /// - read_idle: 120s (longer to accommodate variable chunk timing)
133 ///
134 /// - connect: 10s
135 /// - ttfb: 30s
136 /// - read_idle: 120s
137 /// - write_idle: 30s
138 /// - total: None
139 /// - pool_acquire: 5s
140 pub fn streaming_defaults() -> Self {
141 Self {
142 connect: Some(Duration::from_secs(10)),
143 ttfb: Some(Duration::from_secs(30)),
144 read_idle: Some(Duration::from_secs(120)),
145 write_idle: Some(Duration::from_secs(30)),
146 total: None, // Streams can run indefinitely
147 pool_acquire: Some(Duration::from_secs(5)),
148 }
149 }
150
151 /// Set connect timeout.
152 pub fn connect(mut self, timeout: Duration) -> Self {
153 self.connect = Some(timeout);
154 self
155 }
156
157 /// Set TTFB (time-to-first-byte) timeout.
158 pub fn ttfb(mut self, timeout: Duration) -> Self {
159 self.ttfb = Some(timeout);
160 self
161 }
162
163 /// Set read idle timeout.
164 pub fn read_idle(mut self, timeout: Duration) -> Self {
165 self.read_idle = Some(timeout);
166 self
167 }
168
169 /// Set write idle timeout.
170 pub fn write_idle(mut self, timeout: Duration) -> Self {
171 self.write_idle = Some(timeout);
172 self
173 }
174
175 /// Set total request deadline.
176 pub fn total(mut self, timeout: Duration) -> Self {
177 self.total = Some(timeout);
178 self
179 }
180
181 /// Set pool acquire timeout.
182 pub fn pool_acquire(mut self, timeout: Duration) -> Self {
183 self.pool_acquire = Some(timeout);
184 self
185 }
186
187 /// Disable connect timeout.
188 pub fn no_connect_timeout(mut self) -> Self {
189 self.connect = None;
190 self
191 }
192
193 /// Disable TTFB timeout.
194 pub fn no_ttfb_timeout(mut self) -> Self {
195 self.ttfb = None;
196 self
197 }
198
199 /// Disable read idle timeout.
200 pub fn no_read_idle_timeout(mut self) -> Self {
201 self.read_idle = None;
202 self
203 }
204
205 /// Disable write idle timeout.
206 pub fn no_write_idle_timeout(mut self) -> Self {
207 self.write_idle = None;
208 self
209 }
210
211 /// Disable total timeout.
212 pub fn no_total_timeout(mut self) -> Self {
213 self.total = None;
214 self
215 }
216
217 /// Disable pool acquire timeout.
218 pub fn no_pool_acquire_timeout(mut self) -> Self {
219 self.pool_acquire = None;
220 self
221 }
222}
223
224/// Receive from a channel with idle timeout.
225///
226/// This is a utility for streaming responses. The timeout resets on each
227/// successful receive, making it suitable for detecting hung streams without
228/// killing healthy long-running transfers.
229///
230/// # Example
231///
232/// ```rust,ignore
233/// use specter::timeouts::recv_with_idle_timeout;
234/// use std::time::Duration;
235///
236/// let idle_timeout = Duration::from_secs(60);
237/// while let Some(chunk) = recv_with_idle_timeout(&mut rx, idle_timeout).await? {
238/// // Process chunk...
239/// }
240/// ```
241pub async fn recv_with_idle_timeout<T>(
242 rx: &mut tokio::sync::mpsc::Receiver<T>,
243 idle: Duration,
244) -> crate::Result<Option<T>> {
245 tokio::select! {
246 biased;
247 v = rx.recv() => Ok(v),
248 _ = tokio::time::sleep(idle) => Err(crate::Error::ReadIdleTimeout(idle)),
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn test_api_defaults() {
258 let t = Timeouts::api_defaults();
259 assert_eq!(t.connect, Some(Duration::from_secs(10)));
260 assert_eq!(t.ttfb, Some(Duration::from_secs(30)));
261 assert_eq!(t.read_idle, Some(Duration::from_secs(30)));
262 assert_eq!(t.total, Some(Duration::from_secs(120)));
263 }
264
265 #[test]
266 fn test_streaming_defaults() {
267 let t = Timeouts::streaming_defaults();
268 assert_eq!(t.connect, Some(Duration::from_secs(10)));
269 assert_eq!(t.ttfb, Some(Duration::from_secs(30)));
270 assert_eq!(t.read_idle, Some(Duration::from_secs(120)));
271 assert_eq!(t.total, None); // Key difference
272 }
273
274 #[test]
275 fn test_builder_pattern() {
276 let t = Timeouts::new()
277 .connect(Duration::from_secs(5))
278 .ttfb(Duration::from_secs(15))
279 .read_idle(Duration::from_secs(60));
280
281 assert_eq!(t.connect, Some(Duration::from_secs(5)));
282 assert_eq!(t.ttfb, Some(Duration::from_secs(15)));
283 assert_eq!(t.read_idle, Some(Duration::from_secs(60)));
284 assert_eq!(t.total, None);
285 }
286}