mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
## Why HTTP callers currently cannot bound response bodies. Callers accepting provider-controlled model catalogs need a size limit before decoding the response. ## What changed - Add per-request `response_body_limit_bytes` for buffered, streaming, and error responses, leaving requests unbounded by default. Reject oversized declared lengths early and count observed bytes across chunks. - Return a non-retryable `ResponseTooLarge` error that reports only the byte limit. Preserve HTTP status and headers when a bounded error body fails to read without exceeding the limit. - Expose `ModelsClient::list_models_raw` to fetch bytes and an optional ETag using provider authentication and retries, with an optional body limit. Keep existing `list_models` decoding behavior. ## Testing Add HTTP fixture tests covering size boundaries, chunked and missing-length responses, early rejection, stream termination, interrupted bodies, error text decoding, and request isolation. Add a models-client test verifying that limits survive authentication retries without affecting subsequent ordinary requests. GitOrigin-RevId: 61b8940bc8a549588cee6865a3b5d1cff789074d
253 lines
7.9 KiB
Rust
253 lines
7.9 KiB
Rust
//! Shared HTTP transport with optional limits on complete response bodies.
|
|
//!
|
|
//! Each request's limit applies to observed body bytes, including unsuccessful responses;
|
|
//! declared content lengths are only an additional early-rejection check.
|
|
|
|
use crate::client::HttpClient;
|
|
use crate::client::RequestBuilder;
|
|
use crate::error::TransportError;
|
|
use crate::request::Request;
|
|
use crate::request::RequestBody;
|
|
use crate::request::Response;
|
|
use bytes::Bytes;
|
|
use bytes::BytesMut;
|
|
use futures::StreamExt;
|
|
use futures::stream::BoxStream;
|
|
use http::HeaderMap;
|
|
use http::Method;
|
|
use http::StatusCode;
|
|
use tracing::Level;
|
|
use tracing::enabled;
|
|
use tracing::trace;
|
|
|
|
pub type ByteStream = BoxStream<'static, Result<Bytes, TransportError>>;
|
|
|
|
pub struct StreamResponse {
|
|
pub status: StatusCode,
|
|
pub headers: HeaderMap,
|
|
pub bytes: ByteStream,
|
|
}
|
|
|
|
pub trait HttpTransport: Send + Sync {
|
|
fn execute(
|
|
&self,
|
|
req: Request,
|
|
) -> impl std::future::Future<Output = Result<Response, TransportError>> + Send;
|
|
fn stream(
|
|
&self,
|
|
req: Request,
|
|
) -> impl std::future::Future<Output = Result<StreamResponse, TransportError>> + Send;
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ReqwestTransport {
|
|
client: HttpClient,
|
|
}
|
|
|
|
impl ReqwestTransport {
|
|
pub fn new(client: reqwest::Client) -> Self {
|
|
Self {
|
|
client: HttpClient::new(client),
|
|
}
|
|
}
|
|
|
|
pub fn from_http_client(client: HttpClient) -> Self {
|
|
Self { client }
|
|
}
|
|
|
|
fn build(&self, req: Request) -> Result<RequestBuilder, TransportError> {
|
|
let prepared = req.prepare_body_for_send().map_err(TransportError::Build)?;
|
|
|
|
let Request {
|
|
method,
|
|
url,
|
|
headers: _,
|
|
body: _,
|
|
compression: _,
|
|
timeout,
|
|
response_body_limit_bytes: _,
|
|
} = req;
|
|
|
|
let mut builder = self.client.request(
|
|
Method::from_bytes(method.as_str().as_bytes()).unwrap_or(Method::GET),
|
|
&url,
|
|
);
|
|
|
|
if let Some(timeout) = timeout {
|
|
builder = builder.timeout(timeout);
|
|
}
|
|
|
|
builder = builder.headers(prepared.headers);
|
|
if let Some(body) = prepared.body {
|
|
builder = builder.body(body);
|
|
}
|
|
Ok(builder)
|
|
}
|
|
|
|
fn map_error(err: reqwest::Error) -> TransportError {
|
|
if err.is_connect() {
|
|
TransportError::Connection(err.without_url())
|
|
} else if err.is_timeout() {
|
|
TransportError::Timeout
|
|
} else {
|
|
TransportError::Network(err.to_string())
|
|
}
|
|
}
|
|
|
|
fn trace_request(&self, req: &Request) {
|
|
if self.client.request_logging_enabled() && enabled!(Level::TRACE) {
|
|
trace!(
|
|
"{} to {}: {}",
|
|
req.method,
|
|
req.url,
|
|
request_body_for_trace(req)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn request_body_for_trace(req: &Request) -> String {
|
|
match req.body.as_ref() {
|
|
Some(RequestBody::Json(body)) => body.to_string(),
|
|
Some(RequestBody::EncodedJson(body)) => {
|
|
String::from_utf8_lossy(body.trace_bytes()).into_owned()
|
|
}
|
|
Some(RequestBody::Raw(body)) => format!("<raw body: {} bytes>", body.len()),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
impl HttpTransport for ReqwestTransport {
|
|
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
|
|
self.trace_request(&req);
|
|
|
|
let url = req.url.clone();
|
|
let response_body_limit_bytes = req.response_body_limit_bytes;
|
|
let builder = self.build(req)?;
|
|
let resp = builder.send().await.map_err(Self::map_error)?;
|
|
let status = resp.status();
|
|
let headers = resp.headers().clone();
|
|
let bytes = match response_body_limit_bytes {
|
|
Some(max_bytes) => bounded_response_bytes(resp, max_bytes).await,
|
|
None => resp.bytes().await.map_err(Self::map_error),
|
|
};
|
|
if !status.is_success() {
|
|
let body = match bytes {
|
|
Ok(bytes) => String::from_utf8(bytes.to_vec()).ok(),
|
|
Err(error @ TransportError::ResponseTooLarge { .. }) => return Err(error),
|
|
// Keep bounded diagnostic-body failures from hiding HTTP auth/retry status.
|
|
Err(_) if response_body_limit_bytes.is_some() => None,
|
|
Err(error) => return Err(error),
|
|
};
|
|
return Err(TransportError::Http {
|
|
status,
|
|
url: Some(url),
|
|
headers: Some(headers),
|
|
body,
|
|
});
|
|
}
|
|
Ok(Response {
|
|
status,
|
|
headers,
|
|
body: bytes?,
|
|
})
|
|
}
|
|
|
|
async fn stream(&self, req: Request) -> Result<StreamResponse, TransportError> {
|
|
self.trace_request(&req);
|
|
|
|
let url = req.url.clone();
|
|
let response_body_limit_bytes = req.response_body_limit_bytes;
|
|
let builder = self.build(req)?;
|
|
let resp = builder.send().await.map_err(Self::map_error)?;
|
|
let status = resp.status();
|
|
let headers = resp.headers().clone();
|
|
if !status.is_success() {
|
|
let body = match response_body_limit_bytes {
|
|
Some(max_bytes) => match bounded_response_bytes(resp, max_bytes).await {
|
|
Ok(bytes) => {
|
|
// Reuse the unbounded path's charset, BOM and replacement decoding,
|
|
// but only after the body has passed the byte limit.
|
|
let mut buffered = http::Response::new(bytes);
|
|
*buffered.headers_mut() = headers.clone();
|
|
reqwest::Response::from(buffered).text().await.ok()
|
|
}
|
|
Err(error @ TransportError::ResponseTooLarge { .. }) => return Err(error),
|
|
// A failed diagnostic body must not hide HTTP auth or retry semantics.
|
|
Err(_) => None,
|
|
},
|
|
None => resp.text().await.ok(),
|
|
};
|
|
return Err(TransportError::Http {
|
|
status,
|
|
url: Some(url),
|
|
headers: Some(headers),
|
|
body,
|
|
});
|
|
}
|
|
let bytes = match response_body_limit_bytes {
|
|
Some(max_bytes) => bounded_response_stream(resp, max_bytes)?,
|
|
None => Box::pin(
|
|
resp.bytes_stream()
|
|
.map(|result| result.map_err(Self::map_error)),
|
|
),
|
|
};
|
|
Ok(StreamResponse {
|
|
status,
|
|
headers,
|
|
bytes,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn bounded_response_stream(
|
|
response: reqwest::Response,
|
|
max_bytes: usize,
|
|
) -> Result<ByteStream, TransportError> {
|
|
if response
|
|
.content_length()
|
|
.is_some_and(|length| length > max_bytes as u64)
|
|
{
|
|
return Err(TransportError::ResponseTooLarge { max_bytes });
|
|
}
|
|
|
|
let stream = futures::stream::try_unfold(
|
|
(response, max_bytes),
|
|
move |(mut response, remaining)| async move {
|
|
match response
|
|
.chunk()
|
|
.await
|
|
.map_err(|error| ReqwestTransport::map_error(error.without_url()))?
|
|
{
|
|
Some(chunk) if chunk.len() <= remaining => {
|
|
let remaining = remaining - chunk.len();
|
|
Ok(Some((chunk, (response, remaining))))
|
|
}
|
|
Some(_) => Err(TransportError::ResponseTooLarge { max_bytes }),
|
|
None => Ok(None),
|
|
}
|
|
},
|
|
);
|
|
Ok(Box::pin(stream))
|
|
}
|
|
|
|
async fn bounded_response_bytes(
|
|
response: reqwest::Response,
|
|
max_bytes: usize,
|
|
) -> Result<Bytes, TransportError> {
|
|
let mut stream = bounded_response_stream(response, max_bytes)?;
|
|
let mut body = BytesMut::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
body.extend_from_slice(&chunk?);
|
|
}
|
|
Ok(body.freeze())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "transport_tests.rs"]
|
|
mod tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "transport_limit_tests.rs"]
|
|
mod limit_tests;
|