use std::time::Duration;
use super::api_error::{api_error_has_code, retryable_api_error};
#[derive(Debug, thiserror::Error)]
pub enum ResponsesError {
#[error("failed to resolve OpenAI authorization: {detail}")]
Authorization {
detail: String,
},
#[error("the Responses host transport is not configured")]
HostUnavailable,
#[error("invalid Responses WebSocket URL: {detail}")]
InvalidUrl {
detail: String,
},
#[error("invalid OpenAI authorization header: {detail}")]
InvalidAuthorization {
detail: String,
},
#[error("invalid Responses session identifier header: {detail}")]
InvalidSessionId {
detail: String,
},
#[error("Responses WebSocket handshake exceeded {seconds} seconds")]
HandshakeTimeout {
seconds: u64,
},
#[error("Responses WebSocket handshake failed: {detail}")]
Handshake {
detail: String,
reconnectable: bool,
},
#[error("Responses WebSocket handshake was rejected with HTTP {status}: {body}")]
HandshakeRejected {
status: u16,
body: String,
retry_after: Option<Duration>,
},
#[error("failed to send a Responses WebSocket frame: {detail}")]
Send {
detail: String,
reconnectable: bool,
},
#[error("sending a Responses WebSocket frame exceeded {seconds} seconds")]
SendTimeout {
seconds: u64,
},
#[error("Responses WebSocket produced no event for {seconds} seconds")]
IdleTimeout {
seconds: u64,
},
#[error("Responses WebSocket closed without a close frame")]
UnexpectedEnd,
#[error("failed to receive a Responses WebSocket frame: {detail}")]
Receive {
detail: String,
reconnectable: bool,
},
#[error("Responses WebSocket event was not valid JSON")]
InvalidJson(#[source] serde_json::Error),
#[error("Responses WebSocket returned a binary data frame; expected JSON text")]
UnexpectedBinary,
#[error("failed to encode a Responses WebSocket request")]
EncodeRequest(#[source] serde_json::Error),
#[error("Responses API event did not match its declared type: {event}")]
InvalidPayload {
#[source]
source: serde_json::Error,
event: String,
},
#[error("Responses WebSocket closed {detail}")]
Closed {
detail: String,
},
#[error("Responses API returned an error event: {event}")]
Api {
event: String,
},
#[error("Responses input exceeded the model context window")]
ContextWindowExceeded {
event: String,
},
#[error("Responses API rejected invalid image data: {event}")]
InvalidImageRequest {
event: String,
},
#[error("Responses HTTPS request failed: {detail}")]
HttpRequest {
detail: String,
retryable: bool,
timeout: bool,
},
#[error("Responses HTTPS request was rejected with HTTP {status}: {body}")]
HttpRejected {
status: u16,
body: String,
retry_after: Option<Duration>,
},
#[error("Responses HTTPS stream contained invalid UTF-8: {detail}")]
InvalidSseUtf8 {
detail: String,
},
}
impl ResponsesError {
#[must_use]
pub fn retry_advice(&self) -> Option<RetryAdvice> {
let (class, server_delay) = match self {
Self::Handshake {
reconnectable: true,
..
} => ("handshake_transport", None),
Self::HandshakeTimeout { .. } => ("handshake_timeout", None),
Self::HandshakeRejected {
status,
retry_after,
..
} if *status == 429 => ("handshake_rate_limit", *retry_after),
Self::HandshakeRejected {
status,
retry_after,
..
} if (500..=599).contains(status) => ("handshake_server", *retry_after),
Self::SendTimeout { .. } => ("send_timeout", None),
Self::Send {
reconnectable: true,
..
} => ("send_transport", None),
Self::IdleTimeout { .. } => ("event_idle_timeout", None),
Self::UnexpectedEnd | Self::Closed { .. } => ("premature_close", None),
Self::Receive {
reconnectable: true,
..
} => ("receive_transport", None),
Self::Api { event } => retryable_api_error(event)?,
Self::HttpRequest { timeout: true, .. } => ("https_timeout", None),
Self::HttpRequest {
retryable: true, ..
} => ("https_transport", None),
Self::HttpRejected {
status,
retry_after,
..
} if *status == 429 => ("https_rate_limit", *retry_after),
Self::HttpRejected {
status,
retry_after,
..
} if (500..=599).contains(status) => ("https_server", *retry_after),
_ => return None,
};
Some(RetryAdvice {
class,
server_delay,
})
}
#[must_use]
pub fn class(&self) -> &'static str {
match self {
Self::Authorization { .. } => "authorization",
Self::HostUnavailable => "host_unavailable",
Self::InvalidUrl { .. } => "invalid_url",
Self::InvalidAuthorization { .. } => "invalid_authorization",
Self::InvalidSessionId { .. } => "invalid_session_id",
Self::HandshakeTimeout { .. } => "handshake_timeout",
Self::Handshake { .. } => "handshake",
Self::HandshakeRejected { .. } => "handshake_rejected",
Self::Send { .. } => "send",
Self::SendTimeout { .. } => "send_timeout",
Self::IdleTimeout { .. } => "event_idle_timeout",
Self::UnexpectedEnd => "premature_close",
Self::Receive { .. } => "receive",
Self::InvalidJson(_) => "invalid_json",
Self::UnexpectedBinary => "unexpected_binary",
Self::EncodeRequest(_) => "encode_request",
Self::InvalidPayload { .. } => "invalid_payload",
Self::Closed { .. } => "closed",
Self::Api { event } if api_error_has_code(event, "previous_response_not_found") => {
"checkpoint_missing"
}
Self::Api { .. } => "api",
Self::ContextWindowExceeded { .. } => "context_window_exceeded",
Self::InvalidImageRequest { .. } => "invalid_image_request",
Self::HttpRequest { timeout: true, .. } => "https_timeout",
Self::HttpRequest { .. } => "https_transport",
Self::HttpRejected { status: 429, .. } => "https_rate_limit",
Self::HttpRejected { status, .. } if (500..=599).contains(status) => "https_server",
Self::HttpRejected { .. } => "https_rejected",
Self::InvalidSseUtf8 { .. } => "invalid_sse_utf8",
}
}
#[must_use]
pub fn is_checkpoint_missing(&self) -> bool {
matches!(self, Self::Api { event } if api_error_has_code(event, "previous_response_not_found"))
}
#[must_use]
pub const fn is_context_window_exceeded(&self) -> bool {
matches!(self, Self::ContextWindowExceeded { .. })
}
pub(crate) fn api_event(event: String) -> Self {
if api_error_has_code(&event, "context_length_exceeded") {
Self::ContextWindowExceeded { event }
} else {
Self::Api { event }
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct RetryAdvice {
pub class: &'static str,
pub server_delay: Option<Duration>,
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::ResponsesError;
use crate::transport::api_error::retryable_api_error;
#[test]
fn handshake_rejection_retains_provider_retry_delay() {
let delay = Duration::from_secs(3);
let error = ResponsesError::HandshakeRejected {
status: 429,
body: r#"{"error":"slow down"}"#.to_owned(),
retry_after: Some(delay),
};
let advice = error
.retry_advice()
.expect("HTTP 429 handshake rejection must remain retryable");
assert_eq!(advice.class, "handshake_rate_limit");
assert_eq!(advice.server_delay, Some(delay));
}
#[test]
fn transport_retryability_is_explicit() {
let retryable = ResponsesError::Send {
detail: "socket was replaced".to_owned(),
reconnectable: true,
};
let terminal = ResponsesError::Send {
detail: "host rejected the request".to_owned(),
reconnectable: false,
};
assert_eq!(
retryable.retry_advice().map(|advice| advice.class),
Some("send_transport")
);
assert!(terminal.retry_advice().is_none());
}
#[test]
fn retries_server_error_reported_as_error_type() {
let event = r#"{
"type":"error",
"error":{
"type":"server_error",
"code":null,
"message":"An error occurred while processing the request."
}
}"#;
assert_eq!(
retryable_api_error(event).map(|(class, _)| class),
Some("api_server")
);
}
#[test]
fn classifies_context_window_failures_from_nested_response_errors() {
let error = ResponsesError::api_event(
r#"{
"type": "response.failed",
"response": {
"error": {
"code": "context_length_exceeded",
"message": "maximum context length exceeded"
}
}
}"#
.to_owned(),
);
assert!(error.is_context_window_exceeded());
assert_eq!(error.class(), "context_window_exceeded");
assert!(error.retry_advice().is_none());
}
#[test]
fn error_code_takes_precedence_over_error_type() {
let event = r#"{
"type":"error",
"error":{
"type":"server_error",
"code":"invalid_prompt"
}
}"#;
assert!(retryable_api_error(event).is_none());
}
}