Skip to content

Commit 4ea50ee

Browse files
authored
test: cover nonstream upstream failed response shape (#7)
* test: cover nonstream upstream failed response shape * docs: document nonstream upstream error behavior
1 parent 8de4335 commit 4ea50ee

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# P1 non-stream upstream error behavior
2+
3+
## Scope
4+
5+
This note documents the current non-stream `/v1/responses` behavior when the upstream Chat Completions request fails with an HTTP error.
6+
7+
## Current behavior
8+
9+
- The adapter returns a protocol-shaped Responses object with `status: failed`.
10+
- The response body includes `object: response`, an empty `output`, zeroed `usage`, the original model alias, request metadata, and an `error` object with `type/code: upstream_error`.
11+
- The adapter currently preserves the upstream HTTP status for non-stream requests.
12+
- Streaming upstream errors continue to be delivered through SSE as `response.failed` followed by `[DONE]`.
13+
14+
## Reasoning
15+
16+
The P1-3 risk was that Codex could receive only a plain HTTP `{error: ...}` body and lose the Responses protocol chain. The runtime already emits a Responses `failed` body for non-stream upstream failures, so this milestone locks the behavior with a regression test instead of changing runtime code.
17+
18+
If real Codex subagent validation shows that non-2xx HTTP status still breaks the chain, the next policy change should be explicit: return HTTP 200 with `response.status = failed` for `/v1/responses` upstream errors while leaving `/v1/models` HTTP error behavior unchanged.
19+
20+
## Validation
21+
22+
Run locally:
23+
24+
```bash
25+
cargo fmt --check
26+
cargo test --test nonstream_upstream_error_regression
27+
cargo test --test test_e2e
28+
cargo test
29+
```
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
use axum::extract::State as AxumState;
2+
use axum::http::StatusCode;
3+
use axum::response::IntoResponse;
4+
use axum::routing::post;
5+
use axum::{Json, Router};
6+
use codex_opencode_adapter::config::Config;
7+
use codex_opencode_adapter::server::{self, AppState};
8+
use codex_opencode_adapter::state::StateStore;
9+
use codex_opencode_adapter::upstream::OpenCodeGoClient;
10+
use serde_json::{json, Value};
11+
use std::net::SocketAddr;
12+
use std::sync::Arc;
13+
use tokio::net::TcpListener;
14+
use tokio::sync::{Mutex, Semaphore};
15+
16+
#[derive(Clone)]
17+
struct MockState {
18+
received: Arc<Mutex<Vec<Value>>>,
19+
}
20+
21+
#[tokio::test]
22+
async fn nonstream_upstream_http_error_returns_responses_failed_body() {
23+
let (upstream_addr, _mock, received) = start_error_upstream().await;
24+
let adapter_addr = start_adapter(upstream_addr).await;
25+
let client = reqwest::Client::new();
26+
27+
let resp = client
28+
.post(format!("http://{adapter_addr}/v1/responses"))
29+
.json(&json!({
30+
"model": "opencode-go/deepseek-v4-flash",
31+
"input": "Hello",
32+
"stream": false,
33+
"metadata": {"case": "nonstream-upstream-error"}
34+
}))
35+
.send()
36+
.await
37+
.unwrap();
38+
39+
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
40+
let body: Value = resp.json().await.unwrap();
41+
42+
assert_eq!(body["object"], "response");
43+
assert_eq!(body["status"], "failed");
44+
assert_eq!(body["model"], "opencode-go/deepseek-v4-flash");
45+
assert_eq!(body["metadata"]["case"], "nonstream-upstream-error");
46+
assert!(body["output"].as_array().unwrap().is_empty());
47+
assert_eq!(body["usage"]["input_tokens"], 0);
48+
assert_eq!(body["usage"]["output_tokens"], 0);
49+
assert_eq!(body["usage"]["total_tokens"], 0);
50+
assert_eq!(body["error"]["type"], "upstream_error");
51+
assert_eq!(body["error"]["code"], "upstream_error");
52+
assert!(body["error"]["message"]
53+
.as_str()
54+
.unwrap()
55+
.contains("upstream unavailable"));
56+
57+
let received = received.lock().await;
58+
assert_eq!(received.len(), 1);
59+
assert_eq!(received[0]["model"], "deepseek-v4-flash");
60+
}
61+
62+
async fn start_error_upstream() -> (
63+
SocketAddr,
64+
tokio::task::JoinHandle<()>,
65+
Arc<Mutex<Vec<Value>>>,
66+
) {
67+
let received: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
68+
let state = MockState {
69+
received: Arc::clone(&received),
70+
};
71+
let app = Router::new()
72+
.route("/chat/completions", post(mock_chat_error))
73+
.with_state(state);
74+
75+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
76+
let addr = listener.local_addr().unwrap();
77+
let handle = tokio::spawn(async move {
78+
axum::serve(listener, app).await.unwrap();
79+
});
80+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
81+
(addr, handle, received)
82+
}
83+
84+
async fn mock_chat_error(
85+
AxumState(state): AxumState<MockState>,
86+
Json(payload): Json<Value>,
87+
) -> impl IntoResponse {
88+
state.received.lock().await.push(payload);
89+
(
90+
StatusCode::BAD_GATEWAY,
91+
Json(json!({"error": {"message": "upstream unavailable"}})),
92+
)
93+
}
94+
95+
async fn start_adapter(upstream_addr: SocketAddr) -> SocketAddr {
96+
let db_path = std::env::temp_dir().join(format!(
97+
"nonstream_upstream_error_{}.sqlite",
98+
uuid::Uuid::new_v4()
99+
));
100+
let config = Config {
101+
host: "127.0.0.1".to_string(),
102+
port: 0,
103+
upstream_base: format!("http://{upstream_addr}"),
104+
upstream_key: "test-api-key".to_string(),
105+
local_token: None,
106+
state_db: db_path.to_string_lossy().to_string(),
107+
state_ttl_seconds: 21_600,
108+
timeout_seconds: 30,
109+
max_request_bytes: 8 * 1024 * 1024,
110+
};
111+
let client = OpenCodeGoClient::new(
112+
&config.upstream_base,
113+
&config.upstream_key,
114+
config.timeout_seconds,
115+
)
116+
.unwrap();
117+
let state = StateStore::new(&config.state_db, config.state_ttl_seconds).unwrap();
118+
let app_state = AppState {
119+
config,
120+
client,
121+
state,
122+
capacity: Arc::new(Semaphore::new(10)),
123+
};
124+
let app = server::router(app_state);
125+
126+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
127+
let addr = listener.local_addr().unwrap();
128+
tokio::spawn(async move {
129+
axum::serve(listener, app).await.unwrap();
130+
});
131+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
132+
addr
133+
}

0 commit comments

Comments
 (0)