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///
86/// Consumed bytes are tracked with a cursor rather than drained per line:
87/// draining left-shifts every remaining byte, which turns a chunk carrying K
88/// lines into O(K·chunk) work. The buffer compacts once the consumed prefix
89/// grows past a threshold, so memory stays bounded over a long stream.
90#[derive(Default)]
91pub struct LineBuffer {
92    buf: Vec<u8>,
93    /// Bytes before this offset have already been yielded as lines.
94    cursor: usize,
95}
96
97/// Consumed-prefix size that triggers compaction in [`LineBuffer::next_line`].
98const COMPACT_THRESHOLD: usize = 8 * 1024;
99
100impl LineBuffer {
101    /// Creates an empty buffer.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Appends a raw byte chunk from the stream.
107    pub fn push(&mut self, chunk: &[u8]) {
108        self.buf.extend_from_slice(chunk);
109    }
110
111    /// Removes and returns the next complete line (trailing `\n` stripped),
112    /// or `None` when no complete line is buffered yet.
113    pub fn next_line(&mut self) -> Option<String> {
114        let rel = self.buf[self.cursor..].iter().position(|&b| b == b'\n')?;
115        let end = self.cursor + rel;
116        // Decode straight from the slice; a complete line never splits a
117        // code point, and the trailing newline is excluded by `end`.
118        let line = String::from_utf8_lossy(&self.buf[self.cursor..end]).into_owned();
119        self.cursor = end + 1;
120
121        if self.cursor == self.buf.len() {
122            self.buf.clear();
123            self.cursor = 0;
124        } else if self.cursor >= COMPACT_THRESHOLD {
125            self.buf.drain(..self.cursor);
126            self.cursor = 0;
127        }
128
129        Some(line)
130    }
131
132    /// Drains and returns whatever bytes remain after the last complete line.
133    ///
134    /// A stream can end without a trailing newline, leaving a final,
135    /// otherwise-complete record buffered that `next_line` will never yield.
136    /// Call this once at end-of-stream to recover it. Returns `None` when the
137    /// buffer is empty or the remainder is only whitespace.
138    pub fn take_remaining(&mut self) -> Option<String> {
139        let bytes = std::mem::take(&mut self.buf);
140        let start = std::mem::take(&mut self.cursor);
141        if start >= bytes.len() {
142            return None;
143        }
144        let trimmed = String::from_utf8_lossy(&bytes[start..]).trim().to_string();
145        if trimmed.is_empty() {
146            None
147        } else {
148            Some(trimmed)
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_backoff_within_bounds() {
159        for attempt in 0..8 {
160            let base = (BASE_DELAY_MS * 2u64.pow(attempt)).min(MAX_DELAY_MS);
161            for _ in 0..50 {
162                let d = backoff_delay(attempt, None).as_millis() as u64;
163                assert!(d <= base, "attempt {attempt}: {d} exceeded cap {base}");
164            }
165        }
166    }
167
168    #[test]
169    fn test_backoff_caps_large_attempts() {
170        // Would overflow if 2^attempt were computed without saturation.
171        let d = backoff_delay(100, None);
172        assert!(d.as_millis() as u64 <= MAX_DELAY_MS);
173    }
174
175    #[test]
176    fn test_retry_after_is_honored_as_floor() {
177        let ra = Duration::from_secs(5);
178        let d = backoff_delay(0, Some(ra));
179        assert!(
180            d >= ra,
181            "delay {d:?} should be at least the Retry-After {ra:?}"
182        );
183        // The jitter margin is bounded by the base delay.
184        assert!(d < ra + Duration::from_millis(BASE_DELAY_MS + 1));
185    }
186
187    #[test]
188    fn test_retry_after_clamped_to_cap() {
189        // A server sending an absurd Retry-After must not make us sleep for
190        // hours; the value is clamped to the same ceiling as the exponential
191        // branch (plus the bounded jitter margin).
192        let d = backoff_delay(0, Some(Duration::from_secs(999_999)));
193        assert!(
194            d <= Duration::from_millis(MAX_DELAY_MS + BASE_DELAY_MS + 1),
195            "delay {d:?} exceeded the clamped ceiling"
196        );
197    }
198
199    #[test]
200    fn test_full_jitter_zero_cap() {
201        assert_eq!(full_jitter(0), 0);
202    }
203
204    #[test]
205    fn test_parse_retry_after_secs_numeric() {
206        assert_eq!(parse_retry_after_secs("12"), Some(12));
207        assert_eq!(parse_retry_after_secs("  12  "), Some(12));
208        assert_eq!(parse_retry_after_secs("0"), Some(0));
209    }
210
211    #[test]
212    fn test_parse_retry_after_secs_http_date_ignored() {
213        assert_eq!(
214            parse_retry_after_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
215            None
216        );
217    }
218
219    #[test]
220    fn test_parse_retry_after_secs_garbage() {
221        assert_eq!(parse_retry_after_secs(""), None);
222        assert_eq!(parse_retry_after_secs("abc"), None);
223        assert_eq!(parse_retry_after_secs("1.5"), None);
224        assert_eq!(parse_retry_after_secs("-3"), None);
225    }
226
227    #[test]
228    fn test_ascii_lines() {
229        let mut lb = LineBuffer::new();
230        lb.push(b"hello\nworld\n");
231        assert_eq!(lb.next_line().as_deref(), Some("hello"));
232        assert_eq!(lb.next_line().as_deref(), Some("world"));
233        assert_eq!(lb.next_line(), None);
234    }
235
236    #[test]
237    fn test_incomplete_line_retained() {
238        let mut lb = LineBuffer::new();
239        lb.push(b"partial");
240        assert_eq!(lb.next_line(), None);
241        lb.push(b" line\n");
242        assert_eq!(lb.next_line().as_deref(), Some("partial line"));
243    }
244
245    #[test]
246    fn test_multibyte_char_split_across_chunks() {
247        // "café\n" — the 'é' is two bytes (0xC3 0xA9). Split the stream right
248        // in the middle of that character.
249        let full = "café\n".as_bytes();
250        let split = 4; // 'c','a','f','é'-first-byte
251        let mut lb = LineBuffer::new();
252        lb.push(&full[..split]);
253        assert_eq!(lb.next_line(), None, "no complete line yet");
254        lb.push(&full[split..]);
255        let line = lb.next_line().expect("line completes after second chunk");
256        assert_eq!(line, "café");
257        assert!(!line.contains('\u{FFFD}'), "must not contain U+FFFD");
258    }
259
260    #[test]
261    fn test_emoji_split_across_chunks() {
262        // A 4-byte emoji split byte-by-byte across chunks must not corrupt.
263        let full = "🎉done\n".as_bytes();
264        let mut lb = LineBuffer::new();
265        for b in full {
266            lb.push(&[*b]);
267        }
268        let line = lb.next_line().expect("line completes");
269        assert_eq!(line, "🎉done");
270        assert!(!line.contains('\u{FFFD}'));
271    }
272
273    #[test]
274    fn test_multiple_lines_one_chunk() {
275        let mut lb = LineBuffer::new();
276        lb.push(b"a\nb\nc");
277        assert_eq!(lb.next_line().as_deref(), Some("a"));
278        assert_eq!(lb.next_line().as_deref(), Some("b"));
279        assert_eq!(lb.next_line(), None);
280    }
281
282    #[test]
283    fn test_take_remaining_returns_unterminated_final_line() {
284        // A stream that ends without a trailing newline leaves the last record
285        // buffered; next_line never yields it, take_remaining recovers it.
286        let mut lb = LineBuffer::new();
287        lb.push(b"data: {\"ok\":true}");
288        assert_eq!(lb.next_line(), None, "no complete line without a newline");
289        assert_eq!(lb.take_remaining().as_deref(), Some("data: {\"ok\":true}"));
290        assert_eq!(lb.take_remaining(), None, "buffer drained after take");
291    }
292
293    #[test]
294    fn test_many_lines_one_chunk_and_compaction() {
295        // Push enough newline-terminated lines in one chunk to cross the
296        // compaction threshold mid-drain, with a trailing partial line that
297        // must survive compaction intact.
298        let mut data = Vec::new();
299        let line_count = COMPACT_THRESHOLD / 8 + 16;
300        for i in 0..line_count {
301            data.extend_from_slice(format!("line-{i:04}\n").as_bytes());
302        }
303        data.extend_from_slice(b"partial");
304
305        let mut lb = LineBuffer::new();
306        lb.push(&data);
307        for i in 0..line_count {
308            assert_eq!(
309                lb.next_line().as_deref(),
310                Some(format!("line-{i:04}").as_str())
311            );
312        }
313        assert_eq!(lb.next_line(), None);
314        lb.push(b" tail\n");
315        assert_eq!(lb.next_line().as_deref(), Some("partial tail"));
316    }
317
318    #[test]
319    fn test_take_remaining_after_consumed_lines() {
320        let mut lb = LineBuffer::new();
321        lb.push(b"first\nleftover");
322        assert_eq!(lb.next_line().as_deref(), Some("first"));
323        assert_eq!(lb.take_remaining().as_deref(), Some("leftover"));
324        assert_eq!(lb.take_remaining(), None);
325        assert_eq!(lb.next_line(), None);
326    }
327
328    #[test]
329    fn test_take_remaining_trims_and_none_on_empty() {
330        let mut lb = LineBuffer::new();
331        assert_eq!(lb.take_remaining(), None, "empty buffer yields None");
332        lb.push(b"  \r\n");
333        // The trailing "\r\n" completes a line; next_line consumes it.
334        assert_eq!(lb.next_line().as_deref(), Some("  \r"));
335        assert_eq!(lb.take_remaining(), None);
336        lb.push(b"   ");
337        assert_eq!(
338            lb.take_remaining(),
339            None,
340            "whitespace-only remainder is None"
341        );
342    }
343}