Skip to main content

daimon_core/
stream_util.rs

1//! Shared, dependency-light helpers for chunked HTTP streaming and retry
2//! backoff, used by every reqwest-based provider.
3//!
4//! These helpers were previously copy-pasted into the `daimon` crate's built-in
5//! providers and into each external provider crate (`daimon-provider-gemini`,
6//! `daimon-provider-azure`). They could not live in a shared location before
7//! because the copies were tangled together with reqwest-specific header
8//! parsing, and `daimon-core` deliberately does not depend on reqwest.
9//!
10//! Everything here is std-only. The reqwest-aware `Retry-After` header wrapper
11//! stays in each provider crate and delegates to [`parse_retry_after_secs`].
12
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15/// Base delay for the first retry, in milliseconds.
16pub const BASE_DELAY_MS: u64 = 100;
17
18/// Upper bound on the exponential term (before jitter), in milliseconds.
19///
20/// Without a cap, `2^attempt` overflows and, well before that, produces
21/// absurd multi-hour sleeps for large retry counts.
22pub const MAX_DELAY_MS: u64 = 30_000;
23
24/// Computes the delay to wait before the next retry attempt.
25///
26/// Uses exponential backoff (`BASE_DELAY_MS * 2^attempt`, capped at
27/// `MAX_DELAY_MS`) combined with full jitter: the returned delay is a value in
28/// `[0, backoff]`, which decorrelates retries across concurrent clients.
29///
30/// When the server supplied a `Retry-After` value, that instruction wins — the
31/// delay is at least `retry_after` (clamped to `MAX_DELAY_MS`, the same ceiling
32/// the exponential branch respects, so a hostile or buggy server cannot make us
33/// sleep for hours), with a small jittered margin added so a fleet of clients
34/// that all received the same `Retry-After` do not retry in lockstep.
35pub fn backoff_delay(attempt: u32, retry_after: Option<Duration>) -> Duration {
36    if let Some(ra) = retry_after {
37        let capped = ra.min(Duration::from_millis(MAX_DELAY_MS));
38        return capped + Duration::from_millis(full_jitter(BASE_DELAY_MS));
39    }
40
41    let exp = BASE_DELAY_MS
42        .saturating_mul(2u64.saturating_pow(attempt))
43        .min(MAX_DELAY_MS);
44    Duration::from_millis(full_jitter(exp))
45}
46
47/// Returns a pseudo-random value in `[0, cap_ms]`.
48///
49/// We deliberately avoid pulling in the `rand` crate for a jitter source: the
50/// sub-nanosecond component of the wall clock is more than enough entropy to
51/// stagger retries, which is all full jitter needs.
52pub fn full_jitter(cap_ms: u64) -> u64 {
53    if cap_ms == 0 {
54        return 0;
55    }
56    let entropy = SystemTime::now()
57        .duration_since(UNIX_EPOCH)
58        .map(|d| d.subsec_nanos() as u64)
59        .unwrap_or(0);
60    entropy % (cap_ms + 1)
61}
62
63/// Parses a `Retry-After` value expressed in integer seconds.
64///
65/// HTTP also permits an absolute-date form; providers only emit the delta form
66/// on 429, so we parse the numeric-seconds form and return `None` for anything
67/// we cannot interpret (including the HTTP-date form).
68pub fn parse_retry_after_secs(value: &str) -> Option<u64> {
69    value.trim().parse::<u64>().ok()
70}
71
72/// Accumulates raw stream bytes and yields complete newline-terminated lines.
73///
74/// Streaming responses arrive as arbitrary byte chunks: a single multi-byte
75/// UTF-8 character (e.g. `é`, or an emoji) can be split across two chunks. The
76/// naive `buffer.push_str(&String::from_utf8_lossy(&chunk))` decodes each raw
77/// chunk in isolation, so the leading bytes of a split character decode to
78/// U+FFFD (the replacement character) and the trailing bytes decode to another
79/// — permanently corrupting the text.
80///
81/// `LineBuffer` accumulates raw bytes and only decodes complete,
82/// newline-terminated lines. A `\n` (0x0A) byte can never appear inside a
83/// multi-byte UTF-8 sequence, so decoding a whole line is always safe even when
84/// the underlying chunk boundary fell mid-character.
85#[derive(Default)]
86pub struct LineBuffer {
87    buf: Vec<u8>,
88}
89
90impl LineBuffer {
91    /// Creates an empty buffer.
92    pub fn new() -> Self {
93        Self { buf: Vec::new() }
94    }
95
96    /// Appends a raw byte chunk from the stream.
97    pub fn push(&mut self, chunk: &[u8]) {
98        self.buf.extend_from_slice(chunk);
99    }
100
101    /// Removes and returns the next complete line (trailing `\n` stripped),
102    /// or `None` when no complete line is buffered yet.
103    pub fn next_line(&mut self) -> Option<String> {
104        let pos = self.buf.iter().position(|&b| b == b'\n')?;
105        let line: Vec<u8> = self.buf.drain(..=pos).collect();
106        // Strip the trailing newline; a complete line never splits a code point.
107        Some(String::from_utf8_lossy(&line[..line.len() - 1]).into_owned())
108    }
109
110    /// Drains and returns whatever bytes remain after the last complete line.
111    ///
112    /// A stream can end without a trailing newline, leaving a final,
113    /// otherwise-complete record buffered that `next_line` will never yield.
114    /// Call this once at end-of-stream to recover it. Returns `None` when the
115    /// buffer is empty or the remainder is only whitespace.
116    pub fn take_remaining(&mut self) -> Option<String> {
117        if self.buf.is_empty() {
118            return None;
119        }
120        let bytes = std::mem::take(&mut self.buf);
121        let trimmed = String::from_utf8_lossy(&bytes).trim().to_string();
122        if trimmed.is_empty() {
123            None
124        } else {
125            Some(trimmed)
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_backoff_within_bounds() {
136        for attempt in 0..8 {
137            let base = (BASE_DELAY_MS * 2u64.pow(attempt)).min(MAX_DELAY_MS);
138            for _ in 0..50 {
139                let d = backoff_delay(attempt, None).as_millis() as u64;
140                assert!(d <= base, "attempt {attempt}: {d} exceeded cap {base}");
141            }
142        }
143    }
144
145    #[test]
146    fn test_backoff_caps_large_attempts() {
147        // Would overflow if 2^attempt were computed without saturation.
148        let d = backoff_delay(100, None);
149        assert!(d.as_millis() as u64 <= MAX_DELAY_MS);
150    }
151
152    #[test]
153    fn test_retry_after_is_honored_as_floor() {
154        let ra = Duration::from_secs(5);
155        let d = backoff_delay(0, Some(ra));
156        assert!(
157            d >= ra,
158            "delay {d:?} should be at least the Retry-After {ra:?}"
159        );
160        // The jitter margin is bounded by the base delay.
161        assert!(d < ra + Duration::from_millis(BASE_DELAY_MS + 1));
162    }
163
164    #[test]
165    fn test_retry_after_clamped_to_cap() {
166        // A server sending an absurd Retry-After must not make us sleep for
167        // hours; the value is clamped to the same ceiling as the exponential
168        // branch (plus the bounded jitter margin).
169        let d = backoff_delay(0, Some(Duration::from_secs(999_999)));
170        assert!(
171            d <= Duration::from_millis(MAX_DELAY_MS + BASE_DELAY_MS + 1),
172            "delay {d:?} exceeded the clamped ceiling"
173        );
174    }
175
176    #[test]
177    fn test_full_jitter_zero_cap() {
178        assert_eq!(full_jitter(0), 0);
179    }
180
181    #[test]
182    fn test_parse_retry_after_secs_numeric() {
183        assert_eq!(parse_retry_after_secs("12"), Some(12));
184        assert_eq!(parse_retry_after_secs("  12  "), Some(12));
185        assert_eq!(parse_retry_after_secs("0"), Some(0));
186    }
187
188    #[test]
189    fn test_parse_retry_after_secs_http_date_ignored() {
190        assert_eq!(
191            parse_retry_after_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
192            None
193        );
194    }
195
196    #[test]
197    fn test_parse_retry_after_secs_garbage() {
198        assert_eq!(parse_retry_after_secs(""), None);
199        assert_eq!(parse_retry_after_secs("abc"), None);
200        assert_eq!(parse_retry_after_secs("1.5"), None);
201        assert_eq!(parse_retry_after_secs("-3"), None);
202    }
203
204    #[test]
205    fn test_ascii_lines() {
206        let mut lb = LineBuffer::new();
207        lb.push(b"hello\nworld\n");
208        assert_eq!(lb.next_line().as_deref(), Some("hello"));
209        assert_eq!(lb.next_line().as_deref(), Some("world"));
210        assert_eq!(lb.next_line(), None);
211    }
212
213    #[test]
214    fn test_incomplete_line_retained() {
215        let mut lb = LineBuffer::new();
216        lb.push(b"partial");
217        assert_eq!(lb.next_line(), None);
218        lb.push(b" line\n");
219        assert_eq!(lb.next_line().as_deref(), Some("partial line"));
220    }
221
222    #[test]
223    fn test_multibyte_char_split_across_chunks() {
224        // "café\n" — the 'é' is two bytes (0xC3 0xA9). Split the stream right
225        // in the middle of that character.
226        let full = "café\n".as_bytes();
227        let split = 4; // 'c','a','f','é'-first-byte
228        let mut lb = LineBuffer::new();
229        lb.push(&full[..split]);
230        assert_eq!(lb.next_line(), None, "no complete line yet");
231        lb.push(&full[split..]);
232        let line = lb.next_line().expect("line completes after second chunk");
233        assert_eq!(line, "café");
234        assert!(!line.contains('\u{FFFD}'), "must not contain U+FFFD");
235    }
236
237    #[test]
238    fn test_emoji_split_across_chunks() {
239        // A 4-byte emoji split byte-by-byte across chunks must not corrupt.
240        let full = "🎉done\n".as_bytes();
241        let mut lb = LineBuffer::new();
242        for b in full {
243            lb.push(&[*b]);
244        }
245        let line = lb.next_line().expect("line completes");
246        assert_eq!(line, "🎉done");
247        assert!(!line.contains('\u{FFFD}'));
248    }
249
250    #[test]
251    fn test_multiple_lines_one_chunk() {
252        let mut lb = LineBuffer::new();
253        lb.push(b"a\nb\nc");
254        assert_eq!(lb.next_line().as_deref(), Some("a"));
255        assert_eq!(lb.next_line().as_deref(), Some("b"));
256        assert_eq!(lb.next_line(), None);
257    }
258
259    #[test]
260    fn test_take_remaining_returns_unterminated_final_line() {
261        // A stream that ends without a trailing newline leaves the last record
262        // buffered; next_line never yields it, take_remaining recovers it.
263        let mut lb = LineBuffer::new();
264        lb.push(b"data: {\"ok\":true}");
265        assert_eq!(lb.next_line(), None, "no complete line without a newline");
266        assert_eq!(lb.take_remaining().as_deref(), Some("data: {\"ok\":true}"));
267        assert_eq!(lb.take_remaining(), None, "buffer drained after take");
268    }
269
270    #[test]
271    fn test_take_remaining_trims_and_none_on_empty() {
272        let mut lb = LineBuffer::new();
273        assert_eq!(lb.take_remaining(), None, "empty buffer yields None");
274        lb.push(b"  \r\n");
275        // The trailing "\r\n" completes a line; next_line consumes it.
276        assert_eq!(lb.next_line().as_deref(), Some("  \r"));
277        assert_eq!(lb.take_remaining(), None);
278        lb.push(b"   ");
279        assert_eq!(
280            lb.take_remaining(),
281            None,
282            "whitespace-only remainder is None"
283        );
284    }
285}