Skip to main content

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}