Skip to main content

drep/
http.rs

1//! The two pieces of HTTP drep performs for itself.
2//!
3//! Reviews go through open-agent-sdk, which owns its own transport. What is
4//! left is `drep init` asking two questions over plain GET: which models an
5//! endpoint serves ([`crate::llm::models`]) and what those models accept
6//! ([`crate::llm::quirks`]). Both are one request against a host the user
7//! named, both must be bounded, and both must be non-fatal.
8//!
9//! They live here rather than in either module because the bound is a safety
10//! property, and a safety property written twice is written once and forgotten
11//! once. That is exactly what happened: the quirks fetcher was given a size
12//! ceiling and a chunked read, while the older listing fetcher next to it kept
13//! calling `text()` with no ceiling at all - against an endpoint typed at a
14//! prompt, while holding a key.
15//!
16//! What is deliberately *not* shared is classification. The two callers
17//! disagree about what a status means - a 404 is an ordinary answer for a
18//! listing and a fault for the registry - so each keeps its own error enum and
19//! maps [`ReadError`] into it.
20
21use std::time::Duration;
22
23use thiserror::Error;
24
25/// Why a body could not be read.
26///
27/// Split the way both callers already split their own errors: something went
28/// wrong with the transfer, or the bytes are not text.
29#[derive(Debug, Error, PartialEq, Eq)]
30pub enum ReadError {
31    #[error("{0}")]
32    Transport(String),
33
34    #[error("{0}")]
35    Malformed(String),
36}
37
38/// A client with `timeout` covering the whole request.
39///
40/// The one place a proxy, a user agent or a redirect policy would ever go.
41/// There used to be two of these, differing by accident rather than by choice.
42///
43/// The error is a `String` so each caller can map it into its own enum without
44/// this module knowing about either.
45pub fn client(timeout: Duration) -> Result<reqwest::Client, String> {
46    reqwest::Client::builder()
47        .timeout(timeout)
48        .build()
49        .map_err(|err| err.to_string())
50}
51
52/// Read a response body, refusing one larger than `max_bytes`.
53///
54/// A declared `Content-Length` past the ceiling is refused before a byte is
55/// read, but that is a shortcut rather than the guarantee: reqwest strips the
56/// header from a response it decompresses, and chunked transfer encoding never
57/// sends one. The per-chunk cap is what actually holds, and it counts *decoded*
58/// bytes - which is both what gets allocated and what makes a body that
59/// inflates without limit refusable.
60///
61/// The timeout on the client is not a size bound. A fast host can send a great
62/// deal inside one, and the body is buffered whole.
63pub async fn read_bounded(
64    response: reqwest::Response,
65    max_bytes: u64,
66) -> Result<String, ReadError> {
67    if let Some(len) = response.content_length()
68        && len > max_bytes
69    {
70        return Err(ReadError::Transport(format!(
71            "the response declares {len} bytes, past the {max_bytes}-byte limit"
72        )));
73    }
74
75    let mut body = Vec::new();
76    let mut stream = response;
77    while let Some(chunk) = stream
78        .chunk()
79        .await
80        .map_err(|err| ReadError::Transport(err.to_string()))?
81    {
82        body.extend_from_slice(&chunk);
83        if body.len() as u64 > max_bytes {
84            return Err(ReadError::Transport(format!(
85                "the response exceeded the {max_bytes}-byte limit"
86            )));
87        }
88    }
89
90    String::from_utf8(body)
91        .map_err(|err| ReadError::Malformed(crate::text::excerpt(&err.to_string(), 120)))
92}
93
94#[cfg(test)]
95mod tests;