mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
warning
This commit is contained in:
266
codex-rs/core/src/status.rs
Normal file
266
codex-rs/core/src/status.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use codex_client::HttpTransport;
|
||||
use codex_client::Request;
|
||||
use codex_client::ReqwestTransport;
|
||||
use http::header::CONTENT_TYPE;
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use strum_macros::Display;
|
||||
use tokio::time::Instant;
|
||||
|
||||
const STATUS_WIDGET_URL: &str = "https://status.openai.com/proxy/status.openai.com";
|
||||
const CODEX_COMPONENT_NAME: &str = "Codex";
|
||||
const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, Display, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ComponentHealth {
|
||||
Operational,
|
||||
DegradedPerformance,
|
||||
PartialOutage,
|
||||
MajorOutage,
|
||||
UnderMaintenance,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ComponentHealth {
|
||||
fn operational() -> Self {
|
||||
Self::Operational
|
||||
}
|
||||
|
||||
pub(crate) fn is_operational(self) -> bool {
|
||||
self == Self::Operational
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct IdleWarning {
|
||||
last_event: Instant,
|
||||
idle_timeout: Duration,
|
||||
warning_sent: bool,
|
||||
}
|
||||
|
||||
impl IdleWarning {
|
||||
pub(crate) fn new(idle_timeout: Duration) -> Self {
|
||||
Self {
|
||||
last_event: Instant::now(),
|
||||
idle_timeout,
|
||||
warning_sent: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deadline(&self) -> Instant {
|
||||
self.last_event + self.idle_timeout
|
||||
}
|
||||
|
||||
pub(crate) fn mark_event(&mut self) {
|
||||
self.last_event = Instant::now();
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_warning_message(&mut self) -> Option<String> {
|
||||
if self.warning_sent {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(status) = fetch_codex_health().await {
|
||||
if !status.is_operational() {
|
||||
self.warning_sent = true;
|
||||
return Some(format!(
|
||||
"OpenAI status: {status:?}. Responses may be delayed or stalled."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IdleWarning {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_IDLE_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_codex_health() -> Result<ComponentHealth> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
|
||||
let response = ReqwestTransport::new(client)
|
||||
.execute(Request::new(Method::GET, STATUS_WIDGET_URL.to_string()))
|
||||
.await
|
||||
.context("requesting status widget")?;
|
||||
|
||||
let content_type = response
|
||||
.headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if !content_type.contains("json") {
|
||||
let snippet = String::from_utf8_lossy(&response.body)
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
|
||||
bail!(
|
||||
"Expected JSON from {STATUS_WIDGET_URL}: Content-Type={content_type}. Body starts with: {snippet:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let payload: StatusPayload =
|
||||
serde_json::from_slice(&response.body).context("parsing status widget JSON")?;
|
||||
|
||||
derive_component_health(&payload, CODEX_COMPONENT_NAME)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct StatusPayload {
|
||||
#[serde(default)]
|
||||
summary: Summary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct Summary {
|
||||
#[serde(default)]
|
||||
components: Vec<Component>,
|
||||
#[serde(default)]
|
||||
affected_components: Vec<AffectedComponent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Component {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct AffectedComponent {
|
||||
component_id: String,
|
||||
#[serde(default = "ComponentHealth::operational")]
|
||||
status: ComponentHealth,
|
||||
}
|
||||
|
||||
fn derive_component_health(
|
||||
payload: &StatusPayload,
|
||||
component_name: &str,
|
||||
) -> Result<ComponentHealth> {
|
||||
let component = payload
|
||||
.summary
|
||||
.components
|
||||
.iter()
|
||||
.find(|component| component.name == component_name)
|
||||
.ok_or_else(|| anyhow!("Component {component_name:?} not found in status summary"))?;
|
||||
|
||||
let status = payload
|
||||
.summary
|
||||
.affected_components
|
||||
.iter()
|
||||
.find(|affected| affected.component_id == component.id)
|
||||
.map(|affected| affected.status)
|
||||
.unwrap_or(ComponentHealth::Operational);
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn defaults_to_operational_when_not_affected() {
|
||||
let payload = serde_json::from_value::<StatusPayload>(json!({
|
||||
"summary": {
|
||||
"id": "sum-1",
|
||||
"name": "OpenAI",
|
||||
"components": [
|
||||
{"id": "cmp-1", "name": "Codex", "status_page_id": "page-1"},
|
||||
{"id": "cmp-2", "name": "Chat", "status_page_id": "page-1"}
|
||||
]
|
||||
}
|
||||
}))
|
||||
.expect("valid payload");
|
||||
|
||||
let status = derive_component_health(&payload, "Codex").expect("codex component exists");
|
||||
|
||||
assert_eq!(status, ComponentHealth::Operational);
|
||||
assert!(status.is_operational());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_affected_component_status() {
|
||||
let payload = serde_json::from_value::<StatusPayload>(json!({
|
||||
"summary": {
|
||||
"id": "sum-1",
|
||||
"name": "OpenAI",
|
||||
"components": [
|
||||
{"id": "cmp-1", "name": "Codex", "status_page_id": "page-1"}
|
||||
],
|
||||
"affected_components": [
|
||||
{"component_id": "cmp-1", "status": "major_outage"}
|
||||
]
|
||||
}
|
||||
}))
|
||||
.expect("valid payload");
|
||||
|
||||
let status = derive_component_health(&payload, "Codex").expect("codex component exists");
|
||||
|
||||
assert_eq!(status, ComponentHealth::MajorOutage);
|
||||
assert!(!status.is_operational());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_status_is_preserved_as_unknown() {
|
||||
let payload = serde_json::from_value::<StatusPayload>(json!({
|
||||
"summary": {
|
||||
"id": "sum-1",
|
||||
"name": "OpenAI",
|
||||
"components": [
|
||||
{"id": "cmp-1", "name": "Codex", "status_page_id": "page-1"}
|
||||
],
|
||||
"affected_components": [
|
||||
{"component_id": "cmp-1", "status": "custom_status"}
|
||||
]
|
||||
}
|
||||
}))
|
||||
.expect("valid payload");
|
||||
|
||||
let status = derive_component_health(&payload, "Codex").expect("codex component exists");
|
||||
|
||||
assert_eq!(status, ComponentHealth::Unknown);
|
||||
assert!(!status.is_operational());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_component_returns_error() {
|
||||
let payload = serde_json::from_value::<StatusPayload>(json!({
|
||||
"summary": {
|
||||
"id": "sum-1",
|
||||
"name": "OpenAI",
|
||||
"components": [],
|
||||
"affected_components": []
|
||||
}
|
||||
}))
|
||||
.expect("valid payload");
|
||||
|
||||
let error =
|
||||
derive_component_health(&payload, "Codex").expect_err("missing component should error");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Component \"Codex\" not found in status summary")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user