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