Skip to main content

aimux_provider_utils/
response_handler.rs

1//! Successful and failed response handlers, aligned with AI SDK.
2
3use std::future::Future;
4use std::pin::Pin;
5
6use bytes::Bytes;
7use futures::{StreamExt, stream::BoxStream};
8use serde::de::DeserializeOwned;
9
10use aimux_core::{AiMuxError, ApiCallError};
11
12use crate::extract_response_headers::extract_response_headers;
13use crate::read_response_with_size_limit::{
14    DEFAULT_MAX_DOWNLOAD_SIZE, DEFAULT_MAX_JSON_RESPONSE_SIZE, read_response_with_size_limit,
15};
16
17/// Input supplied to one response handler.
18pub struct ResponseHandlerInput {
19    pub url: String,
20    pub request_body_values: serde_json::Value,
21    pub response: reqwest::Response,
22    pub abort_signal: Option<aimux_core::AbortSignal>,
23    /// Per-request override of the successful-JSON-body size cap, forwarded
24    /// from `HttpRequest::max_json_response_bytes`. Only
25    /// [`create_json_response_handler`] consults this; other handlers ignore
26    /// it.
27    pub max_json_response_bytes: Option<usize>,
28}
29
30/// A parsed value plus response metadata.
31#[derive(Debug)]
32pub struct ResponseHandlerOutput<T> {
33    pub value: T,
34    pub raw_value: Option<serde_json::Value>,
35    pub response_headers: std::collections::HashMap<String, String>,
36}
37
38type HandlerFuture<T> =
39    Pin<Box<dyn Future<Output = Result<ResponseHandlerOutput<T>, AiMuxError>> + Send>>;
40type HandlerFn<T> = dyn FnOnce(ResponseHandlerInput) -> HandlerFuture<T> + Send;
41
42/// Provider-specific fields extracted from a failed response body.
43pub struct ProviderErrorParts {
44    pub message: String,
45    pub provider_code: Option<String>,
46}
47
48/// One-shot async response handler. Exactly one of the successful/failed
49/// handlers passed to an API call is consumed.
50pub struct ResponseHandler<T> {
51    handler: Box<HandlerFn<T>>,
52    /// Whether the handler keeps the response body open as a stream. A
53    /// streaming exchange must not be bounded by the per-exchange response
54    /// timeout in `call_to_api`.
55    streaming: bool,
56}
57
58impl<T> ResponseHandler<T> {
59    /// Create a handler from an async closure.
60    pub fn new<F, Fut>(handler: F) -> Self
61    where
62        F: FnOnce(ResponseHandlerInput) -> Fut + Send + 'static,
63        Fut: Future<Output = Result<ResponseHandlerOutput<T>, AiMuxError>> + Send + 'static,
64    {
65        Self {
66            handler: Box::new(move |input| Box::pin(handler(input))),
67            streaming: false,
68        }
69    }
70
71    /// Mark the handler as streaming: it hands the response body onward as a
72    /// stream instead of reading it to completion, so the per-exchange
73    /// response timeout must not cover body consumption.
74    #[must_use]
75    pub fn streaming(mut self) -> Self {
76        self.streaming = true;
77        self
78    }
79
80    pub(crate) fn is_streaming(&self) -> bool {
81        self.streaming
82    }
83
84    /// Consume the handler and process one response.
85    ///
86    /// # Errors
87    ///
88    /// Returns the handler's contextual API, parse, or caller-abort failure.
89    pub async fn handle(
90        self,
91        input: ResponseHandlerInput,
92    ) -> Result<ResponseHandlerOutput<T>, AiMuxError> {
93        (self.handler)(input).await
94    }
95}
96
97/// Parse a successful JSON response using the endpoint's response type.
98///
99/// The body is size-limited to [`DEFAULT_MAX_JSON_RESPONSE_SIZE`] (or
100/// `HttpRequest::max_json_response_bytes`, when the caller overrides it) —
101/// deliberately smaller than the binary-download bound, since a JSON success
102/// body is deserialized straight into `T` and held alongside the raw bytes.
103#[must_use]
104pub fn create_json_response_handler<T>() -> ResponseHandler<T>
105where
106    T: DeserializeOwned + Send + 'static,
107{
108    ResponseHandler::new(|input| async move {
109        let status = input.response.status().as_u16();
110        let headers = extract_response_headers(input.response.headers());
111        let max_bytes = input
112            .max_json_response_bytes
113            .unwrap_or(DEFAULT_MAX_JSON_RESPONSE_SIZE);
114        let body = read_response_with_size_limit(
115            input.response,
116            &input.url,
117            &input.request_body_values,
118            max_bytes,
119            input.abort_signal.as_ref(),
120        )
121        .await?;
122        // Deserialize straight into `T` instead of parsing a
123        // `serde_json::Value` first and converting that — the previous
124        // two-step parse cloned the intermediate `Value` tree, holding
125        // bytes + `Value` + `T` at once. `raw_value` (needed by callers such
126        // as `Usage.raw`) is a best-effort second parse: `T`'s successful
127        // deserialization already proves `body` is valid JSON, so this
128        // cannot fail in a way that changes the outcome.
129        let value = serde_json::from_slice::<T>(&body).map_err(|error| {
130            AiMuxError::ApiCall(Box::new(ApiCallError {
131                status_code: Some(status),
132                response_body: Some(String::from_utf8_lossy(&body).into_owned()),
133                response_headers: Some(headers.clone()),
134                ..ApiCallError::new(
135                    format!("Invalid JSON response: {error}"),
136                    input.url.clone(),
137                    input.request_body_values.clone(),
138                )
139            }))
140        })?;
141        let raw_value = serde_json::from_slice::<serde_json::Value>(&body).ok();
142        Ok(ResponseHandlerOutput {
143            value,
144            raw_value,
145            response_headers: headers,
146        })
147    })
148}
149
150/// Public error bodies are capped at this size: `ApiCallError` crosses the
151/// FFI as a serialized string and is persisted by recordings, so the body
152/// must stay bounded.
153const ERROR_BODY_PUBLIC_CAP: usize = 64 * 1024;
154
155/// Parse bound for error bodies: enough headroom that an oversize-but-valid
156/// provider error JSON still reaches the mapper (a body truncated mid-JSON
157/// can never parse), while staying bounded — draining an unbounded error
158/// body is a DoS vector.
159const ERROR_BODY_PARSE_CAP: usize = 1024 * 1024;
160
161/// Decode an error body for the public `response_body`, enforcing
162/// [`ERROR_BODY_PUBLIC_CAP`] *after* lossy UTF-8 decoding: U+FFFD expands an
163/// invalid byte to 3 bytes, so capping raw bytes alone can still produce a
164/// ~192 KiB string.
165fn error_body_string(body: &[u8], read_truncated: bool) -> String {
166    let mut value = String::from_utf8_lossy(body).into_owned();
167    let mut truncated = read_truncated;
168    if value.len() > ERROR_BODY_PUBLIC_CAP {
169        let mut end = ERROR_BODY_PUBLIC_CAP;
170        while !value.is_char_boundary(end) {
171            end -= 1;
172        }
173        value.truncate(end);
174        truncated = true;
175    }
176    if truncated {
177        // A cut can land inside a replacement run; drop partial noise before
178        // the marker.
179        while value.ends_with('\u{FFFD}') {
180            value.pop();
181        }
182        value.push_str("…(truncated)");
183    }
184    value
185}
186
187/// Handler for the `{ "error": { "message", "type" | "code" } }` error shape,
188/// which several providers share verbatim. Providers whose error JSON differs
189/// keep their own mapping via [`create_json_error_response_handler`].
190#[must_use]
191pub fn create_standard_json_error_response_handler() -> ResponseHandler<AiMuxError> {
192    create_json_error_response_handler(|data| {
193        let error = data.get("error").unwrap_or(data);
194        ProviderErrorParts {
195            message: error
196                .get("message")
197                .and_then(serde_json::Value::as_str)
198                .unwrap_or_default()
199                .to_owned(),
200            provider_code: error
201                .get("type")
202                .or_else(|| error.get("code"))
203                .and_then(serde_json::Value::as_str)
204                .map(str::to_owned),
205        }
206    })
207}
208
209/// Parse an error JSON and map its data to a provider message and code.
210#[must_use]
211pub fn create_json_error_response_handler<F>(
212    error_to_message_and_code: F,
213) -> ResponseHandler<AiMuxError>
214where
215    F: Fn(&serde_json::Value) -> ProviderErrorParts + Send + 'static,
216{
217    ResponseHandler::new(move |input| async move {
218        let status = input.response.status().as_u16();
219        let status_text = input
220            .response
221            .status()
222            .canonical_reason()
223            .unwrap_or_default()
224            .to_string();
225        let headers = extract_response_headers(input.response.headers());
226        // Best-effort, truncating read: an oversize or dying error body must
227        // not replace the provider's actual error message.
228        let (body, truncated) = crate::read_response_with_size_limit::read_error_body_truncated(
229            input.response,
230            ERROR_BODY_PARSE_CAP,
231            input.abort_signal.as_ref(),
232        )
233        .await?;
234        let response_body = error_body_string(&body, truncated);
235        let parsed_data = serde_json::from_slice::<serde_json::Value>(&body).ok();
236        let parts = parsed_data
237            .as_ref()
238            .map(error_to_message_and_code)
239            .unwrap_or_else(|| ProviderErrorParts {
240                message: status_text.clone(),
241                provider_code: None,
242            });
243        let message = if parts.message.trim().is_empty() {
244            status_text
245        } else {
246            parts.message
247        };
248        let error = AiMuxError::ApiCall(Box::new(ApiCallError {
249            status_code: Some(status),
250            provider_code: parts.provider_code,
251            response_body: Some(response_body),
252            response_headers: Some(headers.clone()),
253            data: parsed_data.map(crate::logging::redact_error_context),
254            is_retryable: aimux_core::error::is_retryable_status(status),
255            ..ApiCallError::new(message, input.url, input.request_body_values)
256        }));
257        Ok(ResponseHandlerOutput {
258            value: error,
259            raw_value: None,
260            response_headers: headers,
261        })
262    })
263}
264
265/// Build the `ApiCallError` for an error event delivered *inside* a stream.
266///
267/// The event rides on an already-successful response, so no HTTP status is
268/// fabricated: `status_code` is set only when the payload itself
269/// carried a numeric HTTP status, and retryability derives from that status
270/// alone. A `retry_after_ms` / `retry_after` field in the payload is
271/// surfaced through the response-headers channel so
272/// `AiMuxError::retry_after_hint()` observes it.
273///
274/// Callers reach this from inside a live stream holding the *raw* request
275/// body and URL they built — unlike the exchange path, where `post_*_to_api`
276/// redacts before any handler runs. Redaction and URL sanitization therefore
277/// happen here, so credentials and data URLs never leak into the public
278/// error (or the recordings that persist it).
279#[must_use]
280pub fn stream_error_api_call(
281    message: impl Into<String>,
282    provider_code: Option<String>,
283    status_code: Option<u16>,
284    error_payload: &serde_json::Value,
285    url: impl Into<String>,
286    request_body_values: serde_json::Value,
287    mut response_headers: std::collections::HashMap<String, String>,
288) -> AiMuxError {
289    // Real headers win; the payload hint fills in only when absent (an
290    // in-stream rate-limit error rides on a 200 whose headers carry none).
291    if !response_headers.contains_key("retry-after-ms")
292        && !response_headers.contains_key("retry-after")
293    {
294        if let Some(ms) = error_payload
295            .get("retry_after_ms")
296            .and_then(serde_json::Value::as_f64)
297        {
298            response_headers.insert("retry-after-ms".into(), ms.to_string());
299        } else if let Some(seconds) = error_payload
300            .get("retry_after")
301            .and_then(serde_json::Value::as_f64)
302        {
303            response_headers.insert("retry-after".into(), seconds.to_string());
304        }
305    }
306    AiMuxError::ApiCall(Box::new(ApiCallError {
307        status_code,
308        provider_code,
309        response_body: Some(error_payload.to_string()),
310        response_headers: Some(response_headers),
311        data: Some(crate::logging::redact_error_context(error_payload.clone())),
312        is_retryable: status_code.is_some_and(aimux_core::error::is_retryable_status),
313        ..ApiCallError::new(
314            message,
315            crate::http::sanitized_request_url(&url.into()),
316            crate::logging::redact_error_context(request_body_values),
317        )
318    }))
319}
320
321/// Read a successful response as bytes.
322#[must_use]
323pub fn create_binary_response_handler() -> ResponseHandler<Bytes> {
324    ResponseHandler::new(|input| async move {
325        let headers = extract_response_headers(input.response.headers());
326        let value = read_response_with_size_limit(
327            input.response,
328            &input.url,
329            &input.request_body_values,
330            DEFAULT_MAX_DOWNLOAD_SIZE,
331            input.abort_signal.as_ref(),
332        )
333        .await?;
334        Ok(ResponseHandlerOutput {
335            value,
336            raw_value: None,
337            response_headers: headers,
338        })
339    })
340}
341
342/// Parse an SSE response and deserialize each `data:` field as `T`.
343#[must_use]
344pub fn create_event_source_response_handler<T>()
345-> ResponseHandler<BoxStream<'static, Result<T, AiMuxError>>>
346where
347    T: DeserializeOwned + Send + 'static,
348{
349    ResponseHandler::new(|input| async move {
350        let headers = extract_response_headers(input.response.headers());
351        let output_headers = headers.clone();
352        let url = input.url;
353        let request_body_values = input.request_body_values;
354        let signal = input.abort_signal;
355        let body_url = url.clone();
356        let body_request_body_values = request_body_values.clone();
357        let body = input.response.bytes_stream().map(move |result| {
358            result.map_err(|error| {
359                AiMuxError::ApiCall(Box::new(ApiCallError {
360                    response_headers: Some(headers.clone()),
361                    is_retryable: true,
362                    ..ApiCallError::new(
363                        error.to_string(),
364                        body_url.clone(),
365                        body_request_body_values.clone(),
366                    )
367                }))
368            })
369        });
370
371        let stream_headers = output_headers.clone();
372        let stream_url = url.clone();
373        let stream_request_body_values = request_body_values.clone();
374        let value: BoxStream<'static, Result<T, AiMuxError>> = Box::pin(async_stream::stream! {
375            let events = aimux_stream::SseStream::new(body);
376            futures::pin_mut!(events);
377            loop {
378                let item = match signal.as_ref() {
379                    Some(signal) => tokio::select! {
380                        biased;
381                        () = signal.cancelled() => {
382                            yield Err(AiMuxError::from_abort_signal(signal));
383                            break;
384                        }
385                        item = events.next() => item,
386                    },
387                    None => events.next().await,
388                };
389                let Some(item) = item else {
390                    break;
391                };
392                match item {
393                    Ok(event) if event.data == "[DONE]" => continue,
394                    Ok(event) => yield serde_json::from_str::<T>(&event.data).map_err(AiMuxError::from),
395                    Err(aimux_stream::SseError::Stream(message)) => {
396                        // Preserve response transport failures as ApiCallError
397                        // items. Framing/parser failures remain JsonParse below.
398                        yield Err(AiMuxError::ApiCall(Box::new(ApiCallError {
399                            response_headers: Some(stream_headers.clone()),
400                            is_retryable: true,
401                            ..ApiCallError::new(
402                                message,
403                                stream_url.clone(),
404                                stream_request_body_values.clone(),
405                            )
406                        })));
407                        break;
408                    }
409                    Err(error) => yield Err(AiMuxError::JsonParse(error.to_string())),
410                }
411            }
412        });
413        Ok(ResponseHandlerOutput {
414            value,
415            raw_value: None,
416            response_headers: output_headers,
417        })
418    })
419    .streaming()
420}
421
422/// Build an error using only status, headers and body.
423#[must_use]
424pub fn create_status_code_error_response_handler() -> ResponseHandler<AiMuxError> {
425    ResponseHandler::new(|input| async move {
426        let status = input.response.status().as_u16();
427        let message = input
428            .response
429            .status()
430            .canonical_reason()
431            .unwrap_or_default()
432            .to_string();
433        let headers = extract_response_headers(input.response.headers());
434        // Same best-effort contract as the JSON error handler: an oversize
435        // or dying body must not replace the provider's actual error text
436        // with a size-limit failure.
437        let (body, truncated) = crate::read_response_with_size_limit::read_error_body_truncated(
438            input.response,
439            ERROR_BODY_PUBLIC_CAP,
440            input.abort_signal.as_ref(),
441        )
442        .await?;
443        let error = AiMuxError::ApiCall(Box::new(ApiCallError {
444            status_code: Some(status),
445            response_body: Some(error_body_string(&body, truncated)),
446            response_headers: Some(headers.clone()),
447            is_retryable: aimux_core::error::is_retryable_status(status),
448            ..ApiCallError::new(message, input.url, input.request_body_values)
449        }));
450        Ok(ResponseHandlerOutput {
451            value: error,
452            raw_value: None,
453            response_headers: headers,
454        })
455    })
456}