mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
495da45643
## Why Responses HTTP requests were converted from `ResponsesApiRequest` into a full `serde_json::Value`. `EndpointSession` then deep-cloned that value for each retry, and the transport serialized and compressed it again before every send. Large histories make those copies expensive. Retry attempts should reuse the same immutable request bytes. ## What - Serialize standard Responses requests directly into a ref-counted `EncodedJsonBody`. - Preserve the Azure path that attaches item IDs before encoding. - Prepare JSON, compression, and derived content headers once before the retry loop. - Clone the prepared request per attempt so body clones only bump the `Bytes` reference count. - Keep auth inside the retry loop. Signing auth sees the exact final headers and body bytes that the transport sends. - Preserve request-body TRACE output. With TRACE plus compression, retain the original JSON bytes for logging; normal requests keep only the final wire bytes. - Leave non-Responses endpoint bodies on the existing `Value` path. ## Performance A temporary release-mode measurement used a 10 MiB JSON body and 10 retry preparations: - old `Value` clone + serialize path: 30 ms total - prepared shared-byte path: less than 1 ms total That is about 3 ms avoided per retry for this payload on the test machine. Each retry also stops allocating another request-sized JSON tree and serialized buffer. Without TRACE, compressed requests retain only the final compressed wire bytes. ## Validation - `just test -p codex-client` — 28 passed - `just test -p codex-api` — 125 passed - `just fix -p codex-client` - `just fix -p codex-api`
330 lines
11 KiB
Rust
330 lines
11 KiB
Rust
use bytes::Bytes;
|
|
use http::Method;
|
|
use reqwest::header::HeaderMap;
|
|
use reqwest::header::HeaderValue;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
use std::time::Duration;
|
|
|
|
/// A JSON request body serialized once into reference-counted bytes.
|
|
///
|
|
/// Clones share the encoded allocation. Internally, the body can also hold the
|
|
/// final compressed wire bytes while retaining the original JSON only when
|
|
/// request-body trace logging is enabled.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct EncodedJsonBody {
|
|
bytes: Bytes,
|
|
trace_bytes: Option<Bytes>,
|
|
prepared: bool,
|
|
}
|
|
|
|
impl EncodedJsonBody {
|
|
/// Serializes `value` into a reusable JSON body.
|
|
pub fn encode<T: Serialize + ?Sized>(value: &T) -> Result<Self, serde_json::Error> {
|
|
serde_json::to_vec(value).map(|bytes| Self {
|
|
bytes: Bytes::from(bytes),
|
|
trace_bytes: None,
|
|
prepared: false,
|
|
})
|
|
}
|
|
|
|
/// Returns the encoded bytes currently stored by this body.
|
|
pub fn as_bytes(&self) -> &[u8] {
|
|
&self.bytes
|
|
}
|
|
|
|
pub(crate) fn trace_bytes(&self) -> &[u8] {
|
|
self.trace_bytes.as_ref().unwrap_or(&self.bytes)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub enum RequestCompression {
|
|
#[default]
|
|
None,
|
|
Zstd,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum RequestBody {
|
|
Json(Value),
|
|
EncodedJson(EncodedJsonBody),
|
|
Raw(Bytes),
|
|
}
|
|
|
|
impl RequestBody {
|
|
pub fn json(&self) -> Option<&Value> {
|
|
match self {
|
|
Self::Json(value) => Some(value),
|
|
Self::EncodedJson(_) | Self::Raw(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PreparedRequestBody {
|
|
pub headers: HeaderMap,
|
|
pub body: Option<Bytes>,
|
|
}
|
|
|
|
impl PreparedRequestBody {
|
|
pub fn body_bytes(&self) -> Bytes {
|
|
self.body.clone().unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Request {
|
|
pub method: Method,
|
|
pub url: String,
|
|
pub headers: HeaderMap,
|
|
pub body: Option<RequestBody>,
|
|
pub compression: RequestCompression,
|
|
pub timeout: Option<Duration>,
|
|
}
|
|
|
|
impl Request {
|
|
pub fn new(method: Method, url: String) -> Self {
|
|
Self {
|
|
method,
|
|
url,
|
|
headers: HeaderMap::new(),
|
|
body: None,
|
|
compression: RequestCompression::None,
|
|
timeout: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_json<T: Serialize>(mut self, body: &T) -> Self {
|
|
self.body = serde_json::to_value(body).ok().map(RequestBody::Json);
|
|
self
|
|
}
|
|
|
|
pub fn with_raw_body(mut self, body: impl Into<Bytes>) -> Self {
|
|
self.body = Some(RequestBody::Raw(body.into()));
|
|
self
|
|
}
|
|
|
|
pub fn with_compression(mut self, compression: RequestCompression) -> Self {
|
|
self.compression = compression;
|
|
self
|
|
}
|
|
|
|
/// Prepares the body once and stores the exact bytes that will be sent.
|
|
///
|
|
/// Cloning the returned request shares the body bytes, so retry attempts do
|
|
/// not repeat JSON serialization or compression. Request-signing auth also
|
|
/// sees the same final headers and bytes that the transport will send.
|
|
pub fn into_prepared(mut self) -> Result<Self, String> {
|
|
let is_json = matches!(
|
|
self.body,
|
|
Some(RequestBody::Json(_) | RequestBody::EncodedJson(_))
|
|
);
|
|
let trace_bytes = if self.compression != RequestCompression::None
|
|
&& tracing::enabled!(target: "codex_client::transport", tracing::Level::TRACE)
|
|
{
|
|
match self.body.as_ref() {
|
|
Some(RequestBody::Json(body)) => Some(Bytes::from(
|
|
serde_json::to_vec(body).map_err(|err| err.to_string())?,
|
|
)),
|
|
Some(RequestBody::EncodedJson(body)) => Some(body.bytes.clone()),
|
|
Some(RequestBody::Raw(_)) | None => None,
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let prepared = self.prepare_body_for_send()?;
|
|
self.headers = prepared.headers;
|
|
self.body = match (is_json, prepared.body) {
|
|
(true, Some(bytes)) => Some(RequestBody::EncodedJson(EncodedJsonBody {
|
|
bytes,
|
|
trace_bytes,
|
|
prepared: true,
|
|
})),
|
|
(false, Some(body)) => Some(RequestBody::Raw(body)),
|
|
(_, None) => None,
|
|
};
|
|
self.compression = RequestCompression::None;
|
|
Ok(self)
|
|
}
|
|
|
|
/// Convert the request body into the exact bytes that will be sent.
|
|
///
|
|
/// Auth schemes such as AWS SigV4 need to sign the final body bytes, including
|
|
/// compression and content headers. Calling this method does not mutate the
|
|
/// request.
|
|
pub fn prepare_body_for_send(&self) -> Result<PreparedRequestBody, String> {
|
|
let headers = self.headers.clone();
|
|
match self.body.as_ref() {
|
|
Some(RequestBody::Raw(raw_body)) => {
|
|
if self.compression != RequestCompression::None {
|
|
return Err("request compression cannot be used with raw bodies".to_string());
|
|
}
|
|
Ok(PreparedRequestBody {
|
|
headers,
|
|
body: Some(raw_body.clone()),
|
|
})
|
|
}
|
|
Some(RequestBody::Json(body)) => {
|
|
let body = EncodedJsonBody::encode(body).map_err(|err| err.to_string())?;
|
|
self.prepare_encoded_json(headers, &body)
|
|
}
|
|
Some(RequestBody::EncodedJson(body)) => self.prepare_encoded_json(headers, body),
|
|
None => Ok(PreparedRequestBody {
|
|
headers,
|
|
body: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn prepare_encoded_json(
|
|
&self,
|
|
mut headers: HeaderMap,
|
|
body: &EncodedJsonBody,
|
|
) -> Result<PreparedRequestBody, String> {
|
|
if body.prepared {
|
|
return Ok(PreparedRequestBody {
|
|
headers,
|
|
body: Some(body.bytes.clone()),
|
|
});
|
|
}
|
|
|
|
let bytes = if self.compression != RequestCompression::None {
|
|
if headers.contains_key(http::header::CONTENT_ENCODING) {
|
|
return Err(
|
|
"request compression was requested but content-encoding is already set"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let pre_compression_bytes = body.bytes.len();
|
|
let compression_start = std::time::Instant::now();
|
|
let (compressed, content_encoding) = match self.compression {
|
|
RequestCompression::None => unreachable!("guarded by compression != None"),
|
|
RequestCompression::Zstd => (
|
|
zstd::stream::encode_all(std::io::Cursor::new(body.as_bytes()), 3)
|
|
.map_err(|err| err.to_string())?,
|
|
HeaderValue::from_static("zstd"),
|
|
),
|
|
};
|
|
let post_compression_bytes = compressed.len();
|
|
let compression_duration = compression_start.elapsed();
|
|
|
|
headers.insert(http::header::CONTENT_ENCODING, content_encoding);
|
|
|
|
tracing::debug!(
|
|
pre_compression_bytes,
|
|
post_compression_bytes,
|
|
compression_duration_ms = compression_duration.as_millis(),
|
|
"Compressed request body with zstd"
|
|
);
|
|
|
|
Bytes::from(compressed)
|
|
} else {
|
|
body.bytes.clone()
|
|
};
|
|
|
|
if !headers.contains_key(http::header::CONTENT_TYPE) {
|
|
headers.insert(
|
|
http::header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/json"),
|
|
);
|
|
}
|
|
|
|
Ok(PreparedRequestBody {
|
|
headers,
|
|
body: Some(bytes),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use http::HeaderValue;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn prepare_body_for_send_serializes_json_and_sets_content_type() {
|
|
let request = Request::new(Method::POST, "https://example.com/v1/responses".to_string())
|
|
.with_json(&json!({"model": "test-model"}));
|
|
|
|
let prepared = request
|
|
.prepare_body_for_send()
|
|
.expect("body should prepare");
|
|
|
|
assert_eq!(
|
|
prepared.body,
|
|
Some(Bytes::from_static(br#"{"model":"test-model"}"#))
|
|
);
|
|
assert_eq!(
|
|
prepared
|
|
.headers
|
|
.get(http::header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("application/json")
|
|
);
|
|
assert_eq!(
|
|
request.body,
|
|
Some(RequestBody::Json(json!({"model": "test-model"})))
|
|
);
|
|
assert_eq!(request.compression, RequestCompression::None);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_body_for_send_rejects_existing_content_encoding_when_compressing() {
|
|
let mut request =
|
|
Request::new(Method::POST, "https://example.com/v1/responses".to_string())
|
|
.with_json(&json!({"model": "test-model"}))
|
|
.with_compression(RequestCompression::Zstd);
|
|
request.headers.insert(
|
|
http::header::CONTENT_ENCODING,
|
|
HeaderValue::from_static("gzip"),
|
|
);
|
|
|
|
let err = request
|
|
.prepare_body_for_send()
|
|
.expect_err("conflicting content-encoding should fail");
|
|
|
|
assert_eq!(
|
|
err,
|
|
"request compression was requested but content-encoding is already set"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn into_prepared_stores_compressed_body_for_reuse() {
|
|
let body =
|
|
EncodedJsonBody::encode(&json!({"model": "test-model"})).expect("JSON should encode");
|
|
let mut request =
|
|
Request::new(Method::POST, "https://example.com/v1/responses".to_string())
|
|
.with_compression(RequestCompression::Zstd);
|
|
request.body = Some(RequestBody::EncodedJson(body));
|
|
let request = request.into_prepared().expect("body should prepare");
|
|
let Some(RequestBody::EncodedJson(body)) = request.body.as_ref() else {
|
|
panic!("expected an encoded JSON body");
|
|
};
|
|
let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body.as_bytes()))
|
|
.expect("body should decompress");
|
|
|
|
assert_eq!(decompressed, br#"{"model":"test-model"}"#);
|
|
assert_eq!(request.compression, RequestCompression::None);
|
|
assert_eq!(
|
|
request.headers.get(http::header::CONTENT_ENCODING),
|
|
Some(&HeaderValue::from_static("zstd"))
|
|
);
|
|
assert_eq!(
|
|
request.headers.get(http::header::CONTENT_TYPE),
|
|
Some(&HeaderValue::from_static("application/json"))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Response {
|
|
pub status: http::StatusCode,
|
|
pub headers: HeaderMap,
|
|
pub body: Bytes,
|
|
}
|