use std::time::Duration;
use golem_wasi_http::header::{CONTENT_TYPE, HeaderMap};
use golem_wasi_http::{
Client, Error as GolemError, IncomingBody, InputStream, Response, StreamError,
};
use crate::error::LLMError;
use crate::http::MAX_HTTP_ERROR_BODY_BYTES;
#[derive(Debug)]
pub(crate) struct HttpResponseData {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: String,
}
fn send_post_request(
url: &str,
bearer_token: &str,
json_body: &[u8],
timeout_seconds: u64,
) -> Result<Response, LLMError> {
let parsed_url = url::Url::parse(url)
.map_err(|e| LLMError::HttpError(format!("invalid request URL: {e}")))?;
let client = build_client(timeout_seconds)?;
if log::log_enabled!(log::Level::Trace) {
log::trace!("WASI HTTP POST {url} ({} byte body)", json_body.len());
}
client
.post(parsed_url)
.bearer_auth(bearer_token)
.header(CONTENT_TYPE, "application/json")
.body(json_body.to_vec())
.send()
.map_err(golem_error_to_llm)
}
fn send_get_request(
url: &str,
bearer_token: &str,
timeout_seconds: u64,
) -> Result<Response, LLMError> {
let parsed_url = url::Url::parse(url)
.map_err(|e| LLMError::HttpError(format!("invalid request URL: {e}")))?;
let client = build_client(timeout_seconds)?;
if log::log_enabled!(log::Level::Trace) {
log::trace!("WASI HTTP GET {url}");
}
client
.get(parsed_url)
.bearer_auth(bearer_token)
.send()
.map_err(golem_error_to_llm)
}
fn read_bounded_error_body(mut response: Response) -> Result<String, LLMError> {
if let Some(content_length) = response.content_length()
&& content_length > MAX_HTTP_ERROR_BODY_BYTES as u64
{
return Ok(format!(
"... [body omitted, Content-Length: {content_length} bytes]"
));
}
let (input_stream, incoming_body) = response.get_raw_input_stream();
let mut reader = WasiResponseStream::new(input_stream, incoming_body);
let mut collected: Vec<u8> = Vec::with_capacity(MAX_HTTP_ERROR_BODY_BYTES.min(8192));
let mut total_received = 0usize;
while let Some(chunk) = reader.read_chunk()? {
total_received += chunk.len();
let remaining = MAX_HTTP_ERROR_BODY_BYTES.saturating_sub(collected.len());
if chunk.len() <= remaining {
collected.extend_from_slice(&chunk);
} else {
collected.extend_from_slice(&chunk[..remaining]);
}
if collected.len() >= MAX_HTTP_ERROR_BODY_BYTES {
let truncated = String::from_utf8_lossy(&collected).into_owned();
return Ok(format!(
"{truncated}... [truncated after reading {total_received} bytes]"
));
}
}
Ok(String::from_utf8_lossy(&collected).into_owned())
}
fn buffer_response(response: Response) -> Result<HttpResponseData, LLMError> {
let status = response.status().as_u16();
let headers = collect_headers(response.headers());
let is_success = (200..300).contains(&status);
let body = if is_success {
response.text().map_err(golem_error_to_llm)?
} else {
read_bounded_error_body(response)?
};
Ok(HttpResponseData {
status,
headers,
body,
})
}
pub(crate) fn post_json_bearer(
url: &str,
bearer_token: &str,
json_body: &[u8],
timeout_seconds: u64,
) -> Result<HttpResponseData, LLMError> {
let response = send_post_request(url, bearer_token, json_body, timeout_seconds)?;
buffer_response(response)
}
pub(crate) fn get_bearer(
url: &str,
bearer_token: &str,
timeout_seconds: u64,
) -> Result<HttpResponseData, LLMError> {
let response = send_get_request(url, bearer_token, timeout_seconds)?;
buffer_response(response)
}
pub(crate) fn post_json_bearer_stream(
url: &str,
bearer_token: &str,
json_body: &[u8],
timeout_seconds: u64,
) -> Result<StreamingResponse, LLMError> {
let response = send_post_request(url, bearer_token, json_body, timeout_seconds)?;
let status = response.status().as_u16();
let headers = collect_headers(response.headers());
Ok(StreamingResponse {
status,
headers,
response,
})
}
pub(crate) struct StreamingResponse {
status: u16,
headers: Vec<(String, String)>,
response: Response,
}
impl StreamingResponse {
pub(crate) fn status(&self) -> u16 {
self.status
}
pub(crate) fn headers(&self) -> &[(String, String)] {
&self.headers
}
pub(crate) fn into_byte_stream(mut self) -> Result<WasiResponseStream, LLMError> {
let (input_stream, incoming_body) = self.response.get_raw_input_stream();
Ok(WasiResponseStream::new(input_stream, incoming_body))
}
pub(crate) fn into_bounded_error_text(self) -> Result<String, LLMError> {
read_bounded_error_body(self.response)
}
}
const WASI_READ_CHUNK_SIZE: u64 = 64 * 1024;
pub(crate) struct WasiResponseStream {
input_stream: Option<InputStream>,
incoming_body: Option<IncomingBody>,
done: bool,
}
impl WasiResponseStream {
fn new(input_stream: InputStream, incoming_body: IncomingBody) -> Self {
Self {
input_stream: Some(input_stream),
incoming_body: Some(incoming_body),
done: false,
}
}
pub(crate) fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, LLMError> {
if self.done {
return Ok(None);
}
let Some(stream) = self.input_stream.as_ref() else {
return Ok(None);
};
match stream.blocking_read(WASI_READ_CHUNK_SIZE) {
Ok(bytes) if bytes.is_empty() => {
self.finish();
Ok(None)
}
Ok(bytes) => Ok(Some(bytes)),
Err(StreamError::Closed) => {
self.finish();
Ok(None)
}
Err(StreamError::LastOperationFailed(err)) => {
self.finish();
Err(LLMError::HttpError(format!(
"WASI response stream read failed: {err}"
)))
}
}
}
fn finish(&mut self) {
self.done = true;
self.input_stream.take();
self.incoming_body.take();
}
}
fn build_client(timeout_seconds: u64) -> Result<Client, LLMError> {
let mut builder = Client::builder();
if timeout_seconds > 0 {
let duration = Duration::from_secs(timeout_seconds);
builder = builder.timeout(duration).connect_timeout(duration);
}
builder.build().map_err(golem_error_to_llm)
}
fn collect_headers(headers: &HeaderMap) -> Vec<(String, String)> {
headers
.iter()
.map(|(name, value)| {
let value_str = value.to_str().unwrap_or_default().to_string();
(name.as_str().to_string(), value_str)
})
.collect()
}
fn golem_error_to_llm(err: GolemError) -> LLMError {
if err.is_timeout() {
LLMError::HttpError(format!("request timed out: {err}"))
} else {
LLMError::HttpError(format!("{err}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn http_response_data_roundtrips_fields() {
let data = HttpResponseData {
status: 429,
headers: vec![("retry-after".to_string(), "5".to_string())],
body: r#"{"error":"rate limited"}"#.to_string(),
};
assert_eq!(data.status, 429);
assert_eq!(
crate::http::find_retry_after(&data.headers),
Some(Duration::from_secs(5))
);
assert_eq!(data.body, r#"{"error":"rate limited"}"#);
}
}