706 lines
24 KiB
Rust
706 lines
24 KiB
Rust
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use futures_util::SinkExt;
|
|
use futures_util::StreamExt;
|
|
use http::HeaderMap;
|
|
use http::HeaderValue;
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use serde_json::map::Map as JsonMap;
|
|
use tokio::net::TcpStream;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::mpsc;
|
|
use tokio::sync::oneshot;
|
|
use tokio::time::Instant;
|
|
use tokio_tungstenite::MaybeTlsStream;
|
|
use tokio_tungstenite::WebSocketStream;
|
|
use tokio_tungstenite::connect_async;
|
|
use tokio_tungstenite::tungstenite::Error as WsError;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
use url::Url;
|
|
|
|
use crate::ProviderError;
|
|
use crate::ProviderEvent;
|
|
use crate::ProviderEventStream;
|
|
use crate::ProviderId;
|
|
use crate::ReasoningFormat;
|
|
use crate::ReasoningProvenance;
|
|
use crate::ResponseHeaders;
|
|
use crate::error::retry_after_from_header_value;
|
|
use crate::error::retry_after_from_headers;
|
|
|
|
use super::SharedTurnState;
|
|
use super::sse::StreamState;
|
|
use super::sse::parse_json_event;
|
|
|
|
const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
|
|
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE: &str = "websocket_connection_limit_reached";
|
|
const WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE: &str = "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.";
|
|
|
|
pub trait ResponsesWebsocketTelemetry: Send + Sync {
|
|
fn on_ws_request(
|
|
&self,
|
|
duration: Duration,
|
|
error: Option<&ProviderError>,
|
|
connection_reused: bool,
|
|
);
|
|
|
|
fn on_ws_event(
|
|
&self,
|
|
result: &Result<Option<Result<Message, WsError>>, ProviderError>,
|
|
duration: Duration,
|
|
);
|
|
}
|
|
|
|
struct WsStream {
|
|
tx_command: mpsc::Sender<WsCommand>,
|
|
rx_message: mpsc::UnboundedReceiver<Result<Message, WsError>>,
|
|
pump_task: tokio::task::JoinHandle<()>,
|
|
}
|
|
|
|
enum WsCommand {
|
|
Send {
|
|
message: Message,
|
|
tx_result: oneshot::Sender<Result<(), WsError>>,
|
|
},
|
|
}
|
|
|
|
impl WsStream {
|
|
fn new(inner: WebSocketStream<MaybeTlsStream<TcpStream>>) -> Self {
|
|
let (tx_command, mut rx_command) = mpsc::channel::<WsCommand>(32);
|
|
let (tx_message, rx_message) = mpsc::unbounded_channel::<Result<Message, WsError>>();
|
|
|
|
let pump_task = tokio::spawn(async move {
|
|
let mut inner = inner;
|
|
loop {
|
|
tokio::select! {
|
|
command = rx_command.recv() => {
|
|
let Some(command) = command else {
|
|
break;
|
|
};
|
|
match command {
|
|
WsCommand::Send { message, tx_result } => {
|
|
let result = inner.send(message).await;
|
|
let should_break = result.is_err();
|
|
let _ = tx_result.send(result);
|
|
if should_break {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
message = inner.next() => {
|
|
let Some(message) = message else {
|
|
break;
|
|
};
|
|
match message {
|
|
Ok(Message::Ping(payload)) => {
|
|
if let Err(err) = inner.send(Message::Pong(payload)).await {
|
|
let _ = tx_message.send(Err(err));
|
|
break;
|
|
}
|
|
}
|
|
Ok(Message::Pong(_)) => {}
|
|
Ok(message @ (Message::Text(_)
|
|
| Message::Binary(_)
|
|
| Message::Close(_)
|
|
| Message::Frame(_))) => {
|
|
let is_close = matches!(message, Message::Close(_));
|
|
if tx_message.send(Ok(message)).is_err() {
|
|
break;
|
|
}
|
|
if is_close {
|
|
break;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let _ = tx_message.send(Err(err));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Self {
|
|
tx_command,
|
|
rx_message,
|
|
pump_task,
|
|
}
|
|
}
|
|
|
|
async fn send(&self, message: Message) -> Result<(), WsError> {
|
|
let (tx_result, rx_result) = oneshot::channel();
|
|
if self
|
|
.tx_command
|
|
.send(WsCommand::Send { message, tx_result })
|
|
.await
|
|
.is_err()
|
|
{
|
|
return Err(WsError::ConnectionClosed);
|
|
}
|
|
rx_result.await.unwrap_or(Err(WsError::ConnectionClosed))
|
|
}
|
|
|
|
async fn next(&mut self) -> Option<Result<Message, WsError>> {
|
|
self.rx_message.recv().await
|
|
}
|
|
}
|
|
|
|
impl Drop for WsStream {
|
|
fn drop(&mut self) {
|
|
self.pump_task.abort();
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ResponsesWebsocketConnection {
|
|
stream: Arc<Mutex<Option<WsStream>>>,
|
|
idle_timeout: Duration,
|
|
response_headers: ResponseHeaders,
|
|
telemetry: Option<Arc<dyn ResponsesWebsocketTelemetry>>,
|
|
}
|
|
|
|
impl std::fmt::Debug for ResponsesWebsocketConnection {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("ResponsesWebsocketConnection")
|
|
.field("stream", &"<ws-stream>")
|
|
.field("idle_timeout", &self.idle_timeout)
|
|
.field("response_headers", &self.response_headers)
|
|
.field("telemetry", &self.telemetry.as_ref().map(|_| "<telemetry>"))
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl ResponsesWebsocketConnection {
|
|
pub async fn connect(
|
|
url: Url,
|
|
headers: HeaderMap,
|
|
turn_state: Option<SharedTurnState>,
|
|
idle_timeout: Duration,
|
|
telemetry: Option<Arc<dyn ResponsesWebsocketTelemetry>>,
|
|
) -> Result<Self, ProviderError> {
|
|
let mut request = url
|
|
.as_str()
|
|
.into_client_request()
|
|
.map_err(|error| ProviderError::InvalidRequest(error.to_string()))?;
|
|
request.headers_mut().extend(headers);
|
|
|
|
let (stream, response) = connect_async(request)
|
|
.await
|
|
.map_err(|error| map_ws_error(error, &url))?;
|
|
|
|
let header_value = response
|
|
.headers()
|
|
.get(X_CODEX_TURN_STATE_HEADER)
|
|
.and_then(|value| value.to_str().ok());
|
|
if let (Some(turn_state), Some(header_value)) = (turn_state, header_value) {
|
|
*turn_state
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
|
Some(header_value.to_string());
|
|
}
|
|
|
|
let response_headers = ResponseHeaders {
|
|
values: response
|
|
.headers()
|
|
.iter()
|
|
.filter_map(|(name, value)| {
|
|
value
|
|
.to_str()
|
|
.ok()
|
|
.map(|value| (name.as_str().to_string(), value.to_string()))
|
|
})
|
|
.collect(),
|
|
};
|
|
|
|
Ok(Self {
|
|
stream: Arc::new(Mutex::new(Some(WsStream::new(stream)))),
|
|
idle_timeout,
|
|
response_headers,
|
|
telemetry,
|
|
})
|
|
}
|
|
|
|
pub async fn is_closed(&self) -> bool {
|
|
self.stream.lock().await.is_none()
|
|
}
|
|
|
|
pub async fn stream_request(
|
|
&self,
|
|
request_body: Value,
|
|
connection_reused: bool,
|
|
) -> Result<ProviderEventStream, ProviderError> {
|
|
let requested_model = request_body
|
|
.get("model")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
self.stream_request_with_provenance(
|
|
request_body,
|
|
connection_reused,
|
|
ReasoningProvenance {
|
|
provider: ProviderId::new("openai"),
|
|
model: requested_model,
|
|
format: ReasoningFormat::OpenAiEncrypted,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn stream_request_with_provenance(
|
|
&self,
|
|
request_body: Value,
|
|
connection_reused: bool,
|
|
provenance: ReasoningProvenance,
|
|
) -> Result<ProviderEventStream, ProviderError> {
|
|
let (tx_event, rx_event) =
|
|
mpsc::unbounded_channel::<Result<ProviderEvent, ProviderError>>();
|
|
let stream = Arc::clone(&self.stream);
|
|
let idle_timeout = self.idle_timeout;
|
|
let response_headers = self.response_headers.clone();
|
|
let telemetry = self.telemetry.clone();
|
|
|
|
let request_text =
|
|
serde_json::to_string(&request_body).map_err(ProviderError::Serialize)?;
|
|
|
|
tokio::spawn(async move {
|
|
if tx_event
|
|
.send(Ok(ProviderEvent::ResponseHeaders(response_headers)))
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
|
|
let mut guard = stream.lock().await;
|
|
let result = {
|
|
let Some(ws_stream) = guard.as_mut() else {
|
|
let _ = tx_event.send(Err(ProviderError::MalformedStream(
|
|
"websocket connection is closed".to_string(),
|
|
)));
|
|
return;
|
|
};
|
|
run_websocket_response_stream(
|
|
ws_stream,
|
|
tx_event.clone(),
|
|
request_text,
|
|
idle_timeout,
|
|
telemetry,
|
|
connection_reused,
|
|
StreamState::new(provenance.provider, provenance.model),
|
|
)
|
|
.await
|
|
};
|
|
|
|
if let Err(err) = result {
|
|
let failed_stream = guard.take();
|
|
drop(guard);
|
|
drop(failed_stream);
|
|
let _ = tx_event.send(Err(err));
|
|
}
|
|
});
|
|
|
|
Ok(rx_event)
|
|
}
|
|
}
|
|
|
|
pub fn merge_request_headers(
|
|
provider_headers: &HeaderMap,
|
|
extra_headers: HeaderMap,
|
|
default_headers: HeaderMap,
|
|
) -> HeaderMap {
|
|
let mut headers = provider_headers.clone();
|
|
headers.extend(extra_headers);
|
|
for (name, value) in &default_headers {
|
|
if let http::header::Entry::Vacant(entry) = headers.entry(name) {
|
|
entry.insert(value.clone());
|
|
}
|
|
}
|
|
headers
|
|
}
|
|
|
|
pub(crate) fn response_create_frame(mut response: Value) -> Value {
|
|
let Value::Object(response_object) = &mut response else {
|
|
return json!({
|
|
"type": "response.create",
|
|
"response": response,
|
|
});
|
|
};
|
|
|
|
response_object.insert(
|
|
"type".to_string(),
|
|
Value::String("response.create".to_string()),
|
|
);
|
|
response_object
|
|
.entry("instructions")
|
|
.or_insert_with(|| Value::String(String::new()));
|
|
response
|
|
}
|
|
|
|
async fn run_websocket_response_stream(
|
|
ws_stream: &mut WsStream,
|
|
tx_event: mpsc::UnboundedSender<Result<ProviderEvent, ProviderError>>,
|
|
request_text: String,
|
|
idle_timeout: Duration,
|
|
telemetry: Option<Arc<dyn ResponsesWebsocketTelemetry>>,
|
|
connection_reused: bool,
|
|
mut state: StreamState,
|
|
) -> Result<(), ProviderError> {
|
|
let request_start = Instant::now();
|
|
let send_result = ws_stream.send(Message::Text(request_text.into())).await;
|
|
let send_error = send_result
|
|
.as_ref()
|
|
.err()
|
|
.map(|error| ProviderError::MalformedStream(error.to_string()));
|
|
if let Some(t) = telemetry.as_ref() {
|
|
t.on_ws_request(
|
|
request_start.elapsed(),
|
|
send_error.as_ref(),
|
|
connection_reused,
|
|
);
|
|
}
|
|
send_result.map_err(|error| ProviderError::MalformedStream(error.to_string()))?;
|
|
|
|
loop {
|
|
let poll_start = Instant::now();
|
|
let message_result = tokio::time::timeout(idle_timeout, ws_stream.next())
|
|
.await
|
|
.map_err(|_| {
|
|
ProviderError::MalformedStream("idle timeout waiting for websocket".into())
|
|
});
|
|
if let Some(t) = telemetry.as_ref() {
|
|
t.on_ws_event(&message_result, poll_start.elapsed());
|
|
}
|
|
let message = match message_result {
|
|
Ok(Some(Ok(message))) => message,
|
|
Ok(Some(Err(error))) => {
|
|
return Err(ProviderError::MalformedStream(error.to_string()));
|
|
}
|
|
Ok(None) => {
|
|
return Err(ProviderError::MalformedStream(
|
|
"stream closed before response.completed".to_string(),
|
|
));
|
|
}
|
|
Err(error) => return Err(error),
|
|
};
|
|
|
|
match message {
|
|
Message::Text(text) => {
|
|
if let Some(mapped) = parse_wrapped_websocket_error_event(&text) {
|
|
let receiver_closed = mapped
|
|
.headers
|
|
.map(|headers| {
|
|
tx_event
|
|
.send(Ok(ProviderEvent::ResponseHeaders(headers)))
|
|
.is_err()
|
|
})
|
|
.unwrap_or(false);
|
|
if receiver_closed {
|
|
return Ok(());
|
|
}
|
|
return Err(mapped.error);
|
|
}
|
|
|
|
let mut saw_message_stopped = false;
|
|
for event in parse_json_event(&text, &mut state)? {
|
|
if matches!(event, ProviderEvent::MessageStopped) {
|
|
saw_message_stopped = true;
|
|
}
|
|
if tx_event.send(Ok(event)).is_err() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
if saw_message_stopped {
|
|
break;
|
|
}
|
|
}
|
|
Message::Binary(_) => {
|
|
return Err(ProviderError::MalformedStream(
|
|
"unexpected binary websocket event".to_string(),
|
|
));
|
|
}
|
|
Message::Close(_) => {
|
|
return Err(ProviderError::MalformedStream(
|
|
"websocket closed by server before response.completed".to_string(),
|
|
));
|
|
}
|
|
Message::Frame(_) => {}
|
|
Message::Ping(_) | Message::Pong(_) => {}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Delay hint for connection-level websocket failures. Short enough that a
|
|
/// tunnel blip or service restart is retried promptly, long enough to avoid
|
|
/// hammering a target that is still coming back up.
|
|
const WS_CONNECTION_RETRY_DELAY: Duration = Duration::from_millis(750);
|
|
|
|
fn map_ws_error(error: WsError, url: &Url) -> ProviderError {
|
|
match error {
|
|
WsError::Http(response) => {
|
|
let status = response.status();
|
|
let retry_after = retry_after_from_headers(response.headers());
|
|
let body = response
|
|
.body()
|
|
.as_ref()
|
|
.and_then(|bytes| String::from_utf8(bytes.clone()).ok())
|
|
.unwrap_or_else(|| format!("websocket connection failed for {url}"));
|
|
ProviderError::Http {
|
|
status,
|
|
body,
|
|
retry_after,
|
|
}
|
|
}
|
|
// Connection lost mid-handshake: the peer went away before the upgrade
|
|
// completed. That is a transient liveness failure (restart, tunnel
|
|
// teardown), not a request the caller must fix — surface it as retryable.
|
|
WsError::ConnectionClosed | WsError::AlreadyClosed => ProviderError::Retryable {
|
|
message: format!("websocket connection closed while connecting to {url}"),
|
|
delay: Some(WS_CONNECTION_RETRY_DELAY),
|
|
},
|
|
// Transport-level failure establishing the connection (e.g. connection
|
|
// refused when an SSH tunnel is down, DNS blips). These clear on their
|
|
// own once the path is back, so the whole turn is worth retrying rather
|
|
// than falling straight through to a terminal failure.
|
|
WsError::Io(error) => ProviderError::Retryable {
|
|
message: format!("websocket connection failed for {url}: {error}"),
|
|
delay: Some(WS_CONNECTION_RETRY_DELAY),
|
|
},
|
|
other => ProviderError::InvalidResponse(other.to_string()),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct MappedWebsocketError {
|
|
error: ProviderError,
|
|
headers: Option<ResponseHeaders>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WrappedWebsocketError {
|
|
code: Option<String>,
|
|
message: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WrappedWebsocketErrorEvent {
|
|
#[serde(rename = "type")]
|
|
kind: String,
|
|
#[serde(alias = "status_code")]
|
|
status: Option<u16>,
|
|
#[serde(default)]
|
|
error: Option<WrappedWebsocketError>,
|
|
#[serde(default)]
|
|
headers: Option<JsonMap<String, Value>>,
|
|
}
|
|
|
|
fn parse_wrapped_websocket_error_event(payload: &str) -> Option<MappedWebsocketError> {
|
|
let event: WrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
|
|
if event.kind != "error" {
|
|
return None;
|
|
}
|
|
|
|
if let Some(error) = event
|
|
.error
|
|
.as_ref()
|
|
.filter(|error| error.code.as_deref() == Some(WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE))
|
|
{
|
|
return Some(MappedWebsocketError {
|
|
error: ProviderError::Retryable {
|
|
message: error
|
|
.message
|
|
.clone()
|
|
.unwrap_or_else(|| WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE.to_string()),
|
|
delay: None,
|
|
},
|
|
headers: event.headers.map(response_headers_from_json),
|
|
});
|
|
}
|
|
|
|
let status = reqwest::StatusCode::from_u16(event.status?).ok()?;
|
|
let body = payload.to_string();
|
|
let headers = event.headers.map(response_headers_from_json);
|
|
// The upgrade's own headers are long gone by the time a rate limit arrives
|
|
// as a wrapped frame, so the only `Retry-After` on this path is the one the
|
|
// frame echoed. Read it here or it is lost.
|
|
let retry_after = headers.as_ref().and_then(retry_after_from_response_headers);
|
|
Some(MappedWebsocketError {
|
|
error: ProviderError::Http {
|
|
status,
|
|
body,
|
|
retry_after,
|
|
},
|
|
headers,
|
|
})
|
|
}
|
|
|
|
/// Reads `Retry-After` out of headers a frame carried as JSON.
|
|
fn retry_after_from_response_headers(headers: &ResponseHeaders) -> Option<Duration> {
|
|
headers
|
|
.values
|
|
.iter()
|
|
.find(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
|
|
.and_then(|(_, value)| retry_after_from_header_value(value))
|
|
}
|
|
|
|
fn response_headers_from_json(headers: JsonMap<String, Value>) -> ResponseHeaders {
|
|
ResponseHeaders {
|
|
values: headers
|
|
.into_iter()
|
|
.filter_map(|(name, value)| {
|
|
json_header_value(value)
|
|
.and_then(|value| value.to_str().ok().map(|value| (name, value.to_string())))
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn json_header_value(value: Value) -> Option<HeaderValue> {
|
|
let value = match value {
|
|
Value::String(value) => value,
|
|
Value::Number(value) => value.to_string(),
|
|
Value::Bool(value) => value.to_string(),
|
|
_ => return None,
|
|
};
|
|
HeaderValue::from_str(&value).ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn merge_request_headers_matches_http_precedence() {
|
|
let mut provider_headers = HeaderMap::new();
|
|
provider_headers.insert(
|
|
"originator",
|
|
HeaderValue::from_static("provider-originator"),
|
|
);
|
|
provider_headers.insert("x-priority", HeaderValue::from_static("provider"));
|
|
|
|
let mut extra_headers = HeaderMap::new();
|
|
extra_headers.insert("x-priority", HeaderValue::from_static("extra"));
|
|
|
|
let mut default_headers = HeaderMap::new();
|
|
default_headers.insert("originator", HeaderValue::from_static("default-originator"));
|
|
default_headers.insert("x-priority", HeaderValue::from_static("default"));
|
|
default_headers.insert("x-default-only", HeaderValue::from_static("default-only"));
|
|
|
|
let merged = merge_request_headers(&provider_headers, extra_headers, default_headers);
|
|
|
|
assert_eq!(
|
|
merged.get("originator"),
|
|
Some(&HeaderValue::from_static("provider-originator"))
|
|
);
|
|
assert_eq!(
|
|
merged.get("x-priority"),
|
|
Some(&HeaderValue::from_static("extra"))
|
|
);
|
|
assert_eq!(
|
|
merged.get("x-default-only"),
|
|
Some(&HeaderValue::from_static("default-only"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn response_create_frame_adds_type_to_responses_request_payload() {
|
|
let frame = response_create_frame(json!({
|
|
"model": "gpt-5",
|
|
"input": []
|
|
}));
|
|
|
|
assert_eq!(frame["type"], "response.create");
|
|
assert_eq!(frame["model"], "gpt-5");
|
|
assert_eq!(frame["input"], json!([]));
|
|
assert_eq!(frame["instructions"], "");
|
|
assert!(frame.get("response").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_websocket_error_preserves_rate_limit_headers() {
|
|
let payload = json!({
|
|
"type": "error",
|
|
"status": 429,
|
|
"error": {
|
|
"type": "usage_limit_reached",
|
|
"message": "The usage limit has been reached"
|
|
},
|
|
"headers": {
|
|
"x-codex-primary-used-percent": "100.0"
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let mapped = parse_wrapped_websocket_error_event(&payload)
|
|
.expect("error payload should map to a provider error");
|
|
let ProviderError::Http { status, .. } = mapped.error else {
|
|
panic!("expected ProviderError::Http");
|
|
};
|
|
assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS);
|
|
assert_eq!(
|
|
mapped
|
|
.headers
|
|
.expect("rate limit headers should be present")
|
|
.values,
|
|
vec![(
|
|
"x-codex-primary-used-percent".to_string(),
|
|
"100.0".to_string()
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ws_io_error_maps_to_retryable_with_delay() {
|
|
let url = Url::parse("wss://example.test/responses").expect("url parses");
|
|
let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused");
|
|
let mapped = map_ws_error(WsError::Io(io), &url);
|
|
let ProviderError::Retryable { message, delay } = mapped else {
|
|
panic!("expected ProviderError::Retryable for a connection-level io error");
|
|
};
|
|
assert_eq!(delay, Some(WS_CONNECTION_RETRY_DELAY));
|
|
// The io error string is preserved so operators can see the real cause.
|
|
assert!(message.contains("Connection refused"), "message: {message}");
|
|
}
|
|
|
|
#[test]
|
|
fn ws_connection_closed_maps_to_retryable() {
|
|
let url = Url::parse("wss://example.test/responses").expect("url parses");
|
|
for error in [WsError::ConnectionClosed, WsError::AlreadyClosed] {
|
|
let mapped = map_ws_error(error, &url);
|
|
assert!(
|
|
matches!(mapped, ProviderError::Retryable { delay: Some(_), .. }),
|
|
"connection-closed handshake failure should be retryable",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn websocket_connection_limit_maps_to_retryable() {
|
|
let payload = json!({
|
|
"type": "error",
|
|
"status": 400,
|
|
"error": {
|
|
"type": "invalid_request_error",
|
|
"code": "websocket_connection_limit_reached",
|
|
"message": WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let mapped = parse_wrapped_websocket_error_event(&payload)
|
|
.expect("connection limit payload should map");
|
|
let ProviderError::Retryable { message, delay } = mapped.error else {
|
|
panic!("expected ProviderError::Retryable");
|
|
};
|
|
assert_eq!(message, WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE);
|
|
assert_eq!(delay, None);
|
|
}
|
|
}
|