refactor error responses into shared utils::response helpers (#33894)

This commit is contained in:
Rain Jiang
2026-08-13 01:30:29 -07:00
committed by GitHub
parent dbebc1deb4
commit fd1e04d952
10 changed files with 187 additions and 159 deletions
@@ -159,12 +159,6 @@ fn hidden_states_rows(vals: &[f32], lens: &[u32]) -> serde_json::Value {
serde_json::Value::Array(rows) serde_json::Value::Array(rows)
} }
/// The `{ "error": { message, code } }` object every error path emits (an SSE
/// event's data, a unary body, or one entry of a batch array).
pub(super) fn error_value(code: u16, message: &str) -> serde_json::Value {
serde_json::json!({ "error": { "message": message, "code": code } })
}
/// Format a decoded [`ChunkEvent`] as one SGLang `/generate` frame's JSON. `rid` /// Format a decoded [`ChunkEvent`] as one SGLang `/generate` frame's JSON. `rid`
/// (response `meta_info.id`) is passed as a string; the event's numeric `rid` is /// (response `meta_info.id`) is passed as a string; the event's numeric `rid` is
/// just the shard routing key. /// just the shard routing key.
@@ -24,14 +24,14 @@ use tokio::sync::mpsc;
use super::AppState; use super::AppState;
use super::frame::{ use super::frame::{
OutputAccumulator, cumulative_frame_string, error_value, frame_value, stream_frame_string, OutputAccumulator, cumulative_frame_string, frame_value, stream_frame_string, tag_value,
tag_value,
}; };
use super::guard::AbortGuard; use super::guard::AbortGuard;
use super::submit::{pre_submit_error, submit}; use super::submit::submit;
use crate::environ::env_bool; use crate::environ::env_bool;
use crate::ids::Rid; use crate::ids::Rid;
use crate::message::{EgressItem, GenerateBody, GenerateRequest, RequestKind, SamplingParams}; use crate::message::{EgressItem, GenerateBody, GenerateRequest, RequestKind, SamplingParams};
use crate::utils::response::{error_response, error_value};
/// The routes this module owns, mounted by `api_server::serve`. /// The routes this module owns, mounted by `api_server::serve`.
pub(super) fn routes() -> Router<AppState> { pub(super) fn routes() -> Router<AppState> {
@@ -40,6 +40,12 @@ pub(super) fn routes() -> Router<AppState> {
.merge(health_routes()) .merge(health_routes())
} }
/// native api error response: unary → `code` plus the JSON `body`,
/// streaming → 200 with one SSE error frame + `[DONE]`.
pub(super) fn native_error(code: StatusCode, message: &str, stream: bool) -> Response {
error_response(code, error_value(code.as_u16(), message), stream)
}
/// `/health` + `/health_generate`. Both env knobs are resolved ONCE here, at /// `/health` + `/health_generate`. Both env knobs are resolved ONCE here, at
/// router build (server startup) — changing them on a live process needs a /// router build (server startup) — changing them on a live process needs a
/// restart. The deep-probe handler is built once with /// restart. The deep-probe handler is built once with
@@ -148,7 +154,7 @@ async fn generate(
// can only answer unary — as Python's does (FastAPI rejects before its // can only answer unary — as Python's does (FastAPI rejects before its
// handler runs). // handler runs).
Err(rejection) => { Err(rejection) => {
return pre_submit_error(StatusCode::BAD_REQUEST, &rejection.body_text(), false); return native_error(StatusCode::BAD_REQUEST, &rejection.body_text(), false);
} }
}; };
let stream = body.stream; let stream = body.stream;
@@ -159,13 +165,13 @@ async fn generate(
// The error carries its own status (a bad batch is `Validation` → 400). // The error carries its own status (a bad batch is `Validation` → 400).
Err(e) => { Err(e) => {
let code = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::BAD_REQUEST); let code = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
return pre_submit_error(code, &e.to_string(), stream); return native_error(code, &e.to_string(), stream);
} }
}; };
// Media I/O (URL downloads, file reads) happens here, on the API runtime // Media I/O (URL downloads, file reads) happens here, on the API runtime
// — never on the MM worker pool (see `prefetch`). // — never on the MM worker pool (see `prefetch`).
if let Err(e) = super::prefetch::prefetch_all(&mut payloads).await { if let Err(e) = super::prefetch::prefetch_all(&mut payloads).await {
return pre_submit_error(StatusCode::BAD_REQUEST, &e, stream); return native_error(StatusCode::BAD_REQUEST, &e, stream);
} }
if !is_batch { if !is_batch {
// `into_requests` guarantees exactly one payload for a non-batch body. // `into_requests` guarantees exactly one payload for a non-batch body.
+14 -39
View File
@@ -4,11 +4,7 @@
//! request and response primitives. Native [`ChunkEvent`] values remain the one //! request and response primitives. Native [`ChunkEvent`] values remain the one
//! backend output type for both unary and streaming responses. //! backend output type for both unary and streaming responses.
use axum::{ use axum::{Router, http::StatusCode, response::Response};
Json, Router,
http::StatusCode,
response::{IntoResponse, Response},
};
use futures::StreamExt; use futures::StreamExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -28,6 +24,7 @@ use super::submit::submit;
use crate::ids::Rid; use crate::ids::Rid;
use crate::message::{ChunkEvent, EgressItem, GenerateRequest, RequestKind}; use crate::message::{ChunkEvent, EgressItem, GenerateRequest, RequestKind};
use crate::runtime::ServerArgs; use crate::runtime::ServerArgs;
use crate::utils::response::error_response;
const MAX_OPENAI_CHOICES: usize = 4096; const MAX_OPENAI_CHOICES: usize = 4096;
@@ -86,10 +83,9 @@ fn unix_seconds_u32() -> u32 {
u32::try_from(unix_seconds()).unwrap_or(u32::MAX) u32::try_from(unix_seconds()).unwrap_or(u32::MAX)
} }
/// The OpenAI `{"error": {...}}` payload: `type` is the SDK-facing error kind /// The OpenAI error payload.
/// (`AuthenticationError` / `InternalServerError` / `BadRequestError`), and pub(super) fn error_payload(code: StatusCode, message: impl Into<String>) -> serde_json::Value {
/// `code` carries the HTTP status — the shape Python's OpenAI frontend emits. let message = message.into();
fn error_payload(code: StatusCode, message: String) -> serde_json::Value {
let error_type = if code == StatusCode::UNAUTHORIZED { let error_type = if code == StatusCode::UNAUTHORIZED {
"AuthenticationError" "AuthenticationError"
} else if code.is_server_error() { } else if code.is_server_error() {
@@ -108,36 +104,15 @@ fn error_payload(code: StatusCode, message: String) -> serde_json::Value {
}) })
} }
/// Shape a `StatusCode` + message into an OpenAI error response, mirroring /// Form an OpenAI error response: unary → `code` plus the JSON `body`,
/// `pre_submit_error`'s rule: unary requests get the JSON error with its /// streaming → 200 with one SSE error frame + `[DONE]`.
/// status; a request whose stream is already committed gets 200 + one SSE pub(super) fn openai_error(code: StatusCode, message: impl Into<String>, stream: bool) -> Response {
/// error frame + `[DONE]`. error_response(code, error_payload(code, message), stream)
pub(super) fn openai_error_response(
code: StatusCode,
message: impl Into<String>,
stream: bool,
) -> Response {
let body = error_payload(code, message.into());
if !stream {
return (code, Json(body)).into_response();
}
super::submit::sse_error_response(body)
}
/// Unary OpenAI error — the common pre-submit case (Python validates before
/// the stream starts and answers 4xx in JSON even for `stream=true`).
fn openai_error(code: StatusCode, message: impl Into<String>) -> Response {
openai_error_response(code, message, false)
}
/// The OpenAI error frame payload for errors raised *inside* a committed
/// stream, where only a `data:` frame can be emitted (the status is folded
/// into the body, since the response status is already 200).
pub(super) fn streaming_error(code: u16, message: impl Into<String>) -> String {
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
error_payload(status, message.into()).to_string()
} }
/// Drain one submitted request to its terminal output: fold frames, disarm
/// `guard` on a natural terminal, and map errors / validation aborts /
/// truncation to `(status, message)` for the OpenAI error shape.
async fn collect_output( async fn collect_output(
mut rx: mpsc::Receiver<EgressItem>, mut rx: mpsc::Receiver<EgressItem>,
guard: &mut AbortGuard, guard: &mut AbortGuard,
@@ -191,10 +166,10 @@ async fn submit_generation(
guard.arm(rid); guard.arm(rid);
Ok(rx) Ok(rx)
} }
// Same rule as `pre_submit_error`: a committed stream gets 200 plus an // Same `error_response` rule: a committed stream gets 200 plus an
// SSE error frame + `[DONE]`, not a unary 503 — but with the OpenAI // SSE error frame + `[DONE]`, not a unary 503 — but with the OpenAI
// error shape, since this is the OpenAI frontend. // error shape, since this is the OpenAI frontend.
Err(_) => Err(openai_error_response( Err(_) => Err(openai_error(
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"service unavailable", "service unavailable",
stream, stream,
@@ -33,8 +33,8 @@ use super::tools::{
parse_chat_tool_calls, parse_chat_tool_calls,
}; };
use super::{ use super::{
AppState, ChatFormatter, collect_output, contains_media, indexed_egress_stream, openai_error, AppState, ChatFormatter, collect_output, contains_media, error_payload, indexed_egress_stream,
streaming_error, submit_generation, unix_seconds_u32, openai_error, submit_generation, unix_seconds_u32,
}; };
use crate::ids::Rid; use crate::ids::Rid;
use crate::message::{ChunkExtras, EgressItem, GenerateRequest, OneOrMany, SamplingParams}; use crate::message::{ChunkExtras, EgressItem, GenerateRequest, OneOrMany, SamplingParams};
@@ -49,25 +49,29 @@ async fn chat_completions(
) -> Response { ) -> Response {
let request = match body { let request = match body {
Ok(Json(request)) => request, Ok(Json(request)) => request,
Err(rejection) => return openai_error(StatusCode::BAD_REQUEST, rejection.body_text()), Err(rejection) => {
return openai_error(StatusCode::BAD_REQUEST, rejection.body_text(), false);
}
}; };
if request.model != state.server_args.served_model_name { if request.model != state.server_args.served_model_name {
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("The model `{}` does not exist", request.model), format!("The model `{}` does not exist", request.model),
false,
); );
} }
if request.messages.is_empty() { if request.messages.is_empty() {
return openai_error(StatusCode::BAD_REQUEST, "messages cannot be empty"); return openai_error(StatusCode::BAD_REQUEST, "messages cannot be empty", false);
} }
if serde_json::to_value(&request.messages).is_ok_and(|messages| contains_media(&messages)) { if serde_json::to_value(&request.messages).is_ok_and(|messages| contains_media(&messages)) {
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"image, audio, video, and file message content is not supported", "image, audio, video, and file message content is not supported",
false,
); );
} }
if request.n == Some(0) { if request.n == Some(0) {
return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1"); return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1", false);
} }
#[allow(deprecated)] #[allow(deprecated)]
let max_tokens = request.max_completion_tokens.or(request.max_tokens); let max_tokens = request.max_completion_tokens.or(request.max_tokens);
@@ -75,6 +79,7 @@ async fn chat_completions(
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"max_completion_tokens must be positive", "max_completion_tokens must be positive",
false,
); );
} }
if request.modalities.as_ref().is_some_and(|modalities| { if request.modalities.as_ref().is_some_and(|modalities| {
@@ -87,6 +92,7 @@ async fn chat_completions(
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"audio, prediction, web search, and multimodal inputs are not supported", "audio, prediction, web search, and multimodal inputs are not supported",
false,
); );
} }
#[allow(deprecated)] #[allow(deprecated)]
@@ -94,6 +100,7 @@ async fn chat_completions(
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"deprecated function_call/functions are not supported; use tools and tool_choice", "deprecated function_call/functions are not supported; use tools and tool_choice",
false,
); );
} }
@@ -110,6 +117,7 @@ async fn chat_completions(
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"tool calls require --tool-call-parser", "tool calls require --tool-call-parser",
false,
); );
} }
// Python gates the split on `request.separate_reasoning` (default true); // Python gates the split on `request.separate_reasoning` (default true);
@@ -143,7 +151,9 @@ async fn chat_completions(
&state.server_args, &state.server_args,
) { ) {
Ok(sampling) => sampling, Ok(sampling) => sampling,
Err(message) => return openai_error(StatusCode::BAD_REQUEST, message), Err(message) => {
return openai_error(StatusCode::BAD_REQUEST, message, false);
}
}; };
let stream = request.stream.unwrap_or(false); let stream = request.stream.unwrap_or(false);
@@ -244,6 +254,7 @@ pub(super) async fn prepare_chat_request(
return Err(openai_error( return Err(openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"this model has no usable chat template", "this model has no usable chat template",
false,
)); ));
}; };
// Template stops first, then the request's own — Python // Template stops first, then the request's own — Python
@@ -255,6 +266,7 @@ pub(super) async fn prepare_chat_request(
openai_error( openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("chat template render failed: {error}"), format!("chat template render failed: {error}"),
false,
) )
})?; })?;
Ok((request, prompt)) Ok((request, prompt))
@@ -428,7 +440,9 @@ pub(super) async fn unary_chat(
for (index, rid, rx) in submitted { for (index, rid, rx) in submitted {
let output = match collect_output(rx, &mut guard, &rid).await { let output = match collect_output(rx, &mut guard, &rid).await {
Ok(output) => output, Ok(output) => output,
Err((status, message)) => return openai_error(status, message), Err((status, message)) => {
return openai_error(status, message, false);
}
}; };
if prompt_tokens == 0 { if prompt_tokens == 0 {
@@ -559,7 +573,7 @@ pub(super) fn chat_event_stream(
id: None, id: None,
event: None, event: None,
comment: None, comment: None,
error: Some(streaming_error(500, "response truncated before completion")), error: Some(error_payload(StatusCode::INTERNAL_SERVER_ERROR, "response truncated before completion").to_string()),
}; };
continue; continue;
}; };
@@ -576,7 +590,7 @@ pub(super) fn chat_event_stream(
id: None, id: None,
event: None, event: None,
comment: None, comment: None,
error: Some(streaming_error(error.http_status(), error.to_string())), error: Some(error_payload(StatusCode::from_u16(error.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), error.to_string()).to_string()),
}; };
continue; continue;
} }
@@ -592,7 +606,7 @@ pub(super) fn chat_event_stream(
id: None, id: None,
event: None, event: None,
comment: None, comment: None,
error: Some(streaming_error(code, message)), error: Some(error_payload(StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), message).to_string()),
}; };
continue; continue;
} }
@@ -23,8 +23,8 @@ use tokio::sync::mpsc;
use super::super::guard::AbortGuard; use super::super::guard::AbortGuard;
use super::super::submit::submit; use super::super::submit::submit;
use super::{ use super::{
AppState, MAX_OPENAI_CHOICES, collect_output, indexed_egress_stream, openai_error, AppState, MAX_OPENAI_CHOICES, collect_output, error_payload, indexed_egress_stream,
streaming_error, submit_generation, unix_seconds_u32, openai_error, submit_generation, unix_seconds_u32,
}; };
use crate::ids::Rid; use crate::ids::Rid;
use crate::message::{ use crate::message::{
@@ -64,7 +64,7 @@ async fn completions(
let request = match body { let request = match body {
Ok(Json(request)) => request, Ok(Json(request)) => request,
Err(rejection) => { Err(rejection) => {
return openai_error(StatusCode::BAD_REQUEST, rejection.body_text()); return openai_error(StatusCode::BAD_REQUEST, rejection.body_text(), false);
} }
}; };
let stream = request.stream.unwrap_or(false); let stream = request.stream.unwrap_or(false);
@@ -74,6 +74,7 @@ async fn completions(
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("The model `{model}` does not exist"), format!("The model `{model}` does not exist"),
false,
); );
} }
@@ -81,33 +82,44 @@ async fn completions(
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"prompt_embeds is not supported by the Rust frontend", "prompt_embeds is not supported by the Rust frontend",
false,
); );
} }
if request.suffix.is_some() { if request.suffix.is_some() {
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"suffix is not supported by this model", "suffix is not supported by this model",
false,
); );
} }
if request.best_of.is_some_and(|best_of| best_of != 1) { if request.best_of.is_some_and(|best_of| best_of != 1) {
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"best_of values greater than 1 are not supported", "best_of values greater than 1 are not supported",
false,
); );
} }
if request.max_tokens == Some(0) { if request.max_tokens == Some(0) {
return openai_error(StatusCode::BAD_REQUEST, "max_tokens must be positive"); return openai_error(
StatusCode::BAD_REQUEST,
"max_tokens must be positive",
false,
);
} }
if request.n == Some(0) { if request.n == Some(0) {
return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1"); return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1", false);
} }
let prompts = match completion_prompt_specs(&request.prompt) { let prompts = match completion_prompt_specs(&request.prompt) {
Ok(prompts) => prompts, Ok(prompts) => prompts,
Err(message) => return openai_error(StatusCode::BAD_REQUEST, message), Err(message) => {
return openai_error(StatusCode::BAD_REQUEST, &message, false);
}
}; };
let mut sampling = match completion_sampling_params(&request) { let mut sampling = match completion_sampling_params(&request) {
Ok(sampling) => sampling, Ok(sampling) => sampling,
Err(message) => return openai_error(StatusCode::BAD_REQUEST, message), Err(message) => {
return openai_error(StatusCode::BAD_REQUEST, &message, false);
}
}; };
if let Err(error) = sampling.normalize( if let Err(error) = sampling.normalize(
state.server_args.skip_tokenizer_init, state.server_args.skip_tokenizer_init,
@@ -117,7 +129,7 @@ async fn completions(
.vocab_size .vocab_size
.unwrap_or(u64::MAX), .unwrap_or(u64::MAX),
) { ) {
return openai_error(StatusCode::BAD_REQUEST, error.to_string()); return openai_error(StatusCode::BAD_REQUEST, error.to_string(), false);
} }
let n = request.n.unwrap_or(1) as usize; let n = request.n.unwrap_or(1) as usize;
@@ -127,6 +139,7 @@ async fn completions(
return openai_error( return openai_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("prompt count times n exceeds the maximum of {MAX_OPENAI_CHOICES}"), format!("prompt count times n exceeds the maximum of {MAX_OPENAI_CHOICES}"),
false,
); );
} }
}; };
@@ -235,6 +248,7 @@ async fn decode_prompt_echo(state: &AppState, token_ids: TokenIds) -> Result<Str
return Err(openai_error( return Err(openai_error(
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"service unavailable", "service unavailable",
false,
)); ));
}; };
match rx.recv().await { match rx.recv().await {
@@ -242,10 +256,11 @@ async fn decode_prompt_echo(state: &AppState, token_ids: TokenIds) -> Result<Str
openai_error( openai_error(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"detokenized prompt is not valid UTF-8", "detokenized prompt is not valid UTF-8",
false,
) )
}), }),
Some(EgressItem::Error(crate::error::Error::Validation(message))) => { Some(EgressItem::Error(crate::error::Error::Validation(message))) => {
Err(openai_error(StatusCode::BAD_REQUEST, message)) Err(openai_error(StatusCode::BAD_REQUEST, &message, false))
} }
Some(EgressItem::Error(error)) => { Some(EgressItem::Error(error)) => {
let status = StatusCode::from_u16(error.http_status()) let status = StatusCode::from_u16(error.http_status())
@@ -253,11 +268,13 @@ async fn decode_prompt_echo(state: &AppState, token_ids: TokenIds) -> Result<Str
Err(openai_error( Err(openai_error(
status, status,
format!("failed to decode prompt for echo: {error}"), format!("failed to decode prompt for echo: {error}"),
false,
)) ))
} }
Some(_) | None => Err(openai_error( Some(_) | None => Err(openai_error(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"failed to decode prompt for echo: reply channel closed", "failed to decode prompt for echo: reply channel closed",
false,
)), )),
} }
} }
@@ -358,7 +375,9 @@ pub(super) async fn unary_completion(
for choice in submitted { for choice in submitted {
let output = match collect_output(choice.rx, &mut guard, &choice.rid).await { let output = match collect_output(choice.rx, &mut guard, &choice.rid).await {
Ok(output) => output, Ok(output) => output,
Err((status, message)) => return openai_error(status, message), Err((status, message)) => {
return openai_error(status, &message, false);
}
}; };
prompt_tokens prompt_tokens
@@ -525,7 +544,7 @@ pub(super) fn completion_event_stream(
while let Some((index, item)) = events.next().await { while let Some((index, item)) = events.next().await {
let Some(item) = item else { let Some(item) = item else {
yield streaming_error(500, "response truncated before completion"); yield error_payload(StatusCode::INTERNAL_SERVER_ERROR, "response truncated before completion").to_string();
continue; continue;
}; };
let output = match item { let output = match item {
@@ -536,7 +555,7 @@ pub(super) fn completion_event_stream(
} }
EgressItem::Error(error) => { EgressItem::Error(error) => {
guard.disarm(&rids[index]); guard.disarm(&rids[index]);
yield streaming_error(error.http_status(), error.to_string()); yield error_payload(StatusCode::from_u16(error.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), error.to_string()).to_string();
continue; continue;
} }
EgressItem::Control(_) | EgressItem::Data(_) => continue, EgressItem::Control(_) | EgressItem::Data(_) => continue,
@@ -547,7 +566,7 @@ pub(super) fn completion_event_stream(
.as_ref() .as_ref()
.and_then(|reason| reason.abort_status()) .and_then(|reason| reason.abort_status())
{ {
yield streaming_error(code, message); yield error_payload(StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), message).to_string();
continue; continue;
} }
@@ -28,6 +28,7 @@ async fn retrieve_model(State(state): State<AppState>, Path(model): Path<String>
return openai_error( return openai_error(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
format!("The model `{model}` does not exist"), format!("The model `{model}` does not exist"),
false,
); );
} }
Json(model_card(&state)).into_response() Json(model_card(&state)).into_response()
@@ -18,7 +18,7 @@ use axum::response::Response;
use serde_json::json; use serde_json::json;
use tower::util::ServiceExt; use tower::util::ServiceExt;
use super::routes; use super::{openai_error, routes};
use crate::ids::Rid; use crate::ids::Rid;
use crate::message::{ChunkEvent, EgressItem}; use crate::message::{ChunkEvent, EgressItem};
use crate::runtime::ServerArgs; use crate::runtime::ServerArgs;
@@ -157,13 +157,13 @@ pub(super) async fn body_json(response: Response) -> serde_json::Value {
serde_json::from_slice(&bytes).unwrap() serde_json::from_slice(&bytes).unwrap()
} }
/// The common StatusCode→error helper follows `pre_submit_error`'s shape: /// The common StatusCode→error helper follows `error_response`'s shape:
/// unary requests get the JSON error with its status; a committed stream gets /// unary requests get the JSON error with its status; a committed stream gets
/// 200 + one SSE error frame + `[DONE]`, and the frame carries the OpenAI /// 200 + one SSE error frame + `[DONE]`, and the frame carries the OpenAI
/// error fields (`type`, `param`, `code`) that the SDKs dispatch on. /// error fields (`type`, `param`, `code`) that the SDKs dispatch on.
#[tokio::test] #[tokio::test]
async fn openai_error_response_covers_unary_and_sse() { async fn openai_error_response_covers_unary_and_sse() {
let unary = super::openai_error_response(StatusCode::BAD_REQUEST, "bad input", false); let unary = openai_error(StatusCode::BAD_REQUEST, "bad input", false);
assert_eq!(unary.status(), StatusCode::BAD_REQUEST); assert_eq!(unary.status(), StatusCode::BAD_REQUEST);
let value = body_json(unary).await; let value = body_json(unary).await;
assert_eq!(value["error"]["message"], "bad input"); assert_eq!(value["error"]["message"], "bad input");
@@ -171,7 +171,7 @@ async fn openai_error_response_covers_unary_and_sse() {
assert_eq!(value["error"]["code"], 400); assert_eq!(value["error"]["code"], 400);
assert!(value["error"]["param"].is_null()); assert!(value["error"]["param"].is_null());
let streamed = super::openai_error_response(StatusCode::BAD_REQUEST, "bad input", true); let streamed = openai_error(StatusCode::BAD_REQUEST, "bad input", true);
assert_eq!(streamed.status(), StatusCode::OK); assert_eq!(streamed.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(streamed.into_body(), 64 * 1024) let bytes = axum::body::to_bytes(streamed.into_body(), 64 * 1024)
.await .await
@@ -290,7 +290,7 @@ async fn basic_openai_router_excludes_responses_api() {
/// A closed tm inbox with a *streaming* request must answer inside the /// A closed tm inbox with a *streaming* request must answer inside the
/// committed stream: 200 + one OpenAI-shaped SSE error frame + `[DONE]` (the /// committed stream: 200 + one OpenAI-shaped SSE error frame + `[DONE]` (the
/// same rule `pre_submit_error` applies to the native API), not a unary 503. /// same `error_response` rule the native API applies), not a unary 503.
#[tokio::test] #[tokio::test]
async fn streaming_submit_failure_answers_inside_the_stream() { async fn streaming_submit_failure_answers_inside_the_stream() {
let app = routes().with_state(app_state(senders_closed())); let app = routes().with_state(app_state(senders_closed()));
+4 -80
View File
@@ -2,20 +2,10 @@
//! module: mint the client-visible rid (uuid hex, Python-parity), build the //! module: mint the client-visible rid (uuid hex, Python-parity), build the
//! `Request`, and hand it to the TM with an egress receiver for the response. //! `Request`, and hand it to the TM with an egress receiver for the response.
use std::convert::Infallible; use axum::{http::StatusCode, response::Response};
use axum::{
Json,
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use super::AppState; use super::{AppState, native_api::native_error};
use super::frame::error_value;
use crate::fsm::RequestState; use crate::fsm::RequestState;
use crate::ids::Rid; use crate::ids::Rid;
use crate::message::{EgressItem, EgressSink, Request, RequestKind}; use crate::message::{EgressItem, EgressSink, Request, RequestKind};
@@ -29,8 +19,7 @@ pub(super) async fn submit(
state: &AppState, state: &AppState,
kind: RequestKind, kind: RequestKind,
// `stream`: the client is reading an SSE stream, so it expects 200 plus an // `stream`: the client is reading an SSE stream, so it expects 200 plus an
// error frame rather than a 4xx — same rule `pre_submit_error` applies // error frame rather than a 4xx — `utils::response::error_response`'s rule.
// everywhere else.
stream: bool, stream: bool,
) -> Result<(Rid, mpsc::Receiver<EgressItem>), Response> { ) -> Result<(Rid, mpsc::Receiver<EgressItem>), Response> {
let rid = match &kind { let rid = match &kind {
@@ -61,7 +50,7 @@ pub(super) async fn submit(
Err(_) => { Err(_) => {
tracing::error!(%rid, "tm inbox closed; request rejected"); tracing::error!(%rid, "tm inbox closed; request rejected");
// Return 503 so the client can retry. // Return 503 so the client can retry.
Err(pre_submit_error( Err(native_error(
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"service unavailable", "service unavailable",
stream, stream,
@@ -69,68 +58,3 @@ pub(super) async fn submit(
} }
} }
} }
/// Shape an error that occurs *before* (or instead of) a successful submit into a
/// client response. Two parity points with Python's `generate_request`. The body
/// is the same `{"error": {...}}` object every other path emits — not bare text,
/// which a client parsing JSON chokes on. And a streaming request gets 200 plus
/// one SSE error frame and `[DONE]`, not a 4xx: the client has already committed
/// to reading a stream, and Python answers it inside `stream_results()`.
pub(super) fn pre_submit_error(code: StatusCode, message: &str, stream: bool) -> Response {
let body = error_value(code.as_u16(), message);
if !stream {
return (code, Json(body)).into_response();
}
sse_error_response(body)
}
/// A 200 SSE response carrying one error frame + `[DONE]` — how a stream the
/// client is already committed to reading reports a failure. Shared by every
/// endpoint family: the native API via [`pre_submit_error`] and the OpenAI
/// frontend's `openai_error_response`.
pub(super) fn sse_error_response(body: serde_json::Value) -> Response {
let frames = [body.to_string(), "[DONE]".to_string()];
Sse::new(futures::stream::iter(
frames.map(|data| Ok::<_, Infallible>(Event::default().data(data))),
))
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
/// Unary pre-submit errors are a 4xx/5xx with a JSON `{"error":...}` body;
/// streaming ones are 200 + an SSE error frame + `[DONE]`, because Python
/// answers from inside `stream_results()` once the stream is committed.
#[tokio::test]
async fn pre_submit_errors_match_python_shape() {
let unary = pre_submit_error(StatusCode::BAD_REQUEST, "bad input", false);
assert_eq!(unary.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(unary.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).expect("JSON body");
assert_eq!(v["error"]["message"], "bad input");
assert_eq!(v["error"]["code"], 400);
let streamed = pre_submit_error(StatusCode::BAD_REQUEST, "bad input", true);
assert_eq!(
streamed.status(),
StatusCode::OK,
"the stream itself is 200"
);
let body = axum::body::to_bytes(streamed.into_body(), 64 * 1024)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(
text.contains(r#""code":400"#),
"carries the status in-band: {text}"
);
assert!(
text.trim_end().ends_with("data: [DONE]"),
"terminated: {text}"
);
}
}
+1
View File
@@ -1,4 +1,5 @@
//! Shared helpers with no home in a pipeline stage. //! Shared helpers with no home in a pipeline stage.
pub mod regex; pub mod regex;
pub mod response;
pub mod sock; pub mod sock;
+94
View File
@@ -0,0 +1,94 @@
//! Shared HTTP error-response shaping for the api-server endpoint modules.
//!
//! Two mechanics live here; the WIRE SHAPES stay owned by their endpoints:
//! the native `{"error": {"message", "code"}}` body (Python
//! `http_server.generate_request` parity) built by [`error_value`], and the
//! SSE variant [`sse_error_response`] used by any
//! endpoint family (native and OpenAI alike — the caller supplies its own
//! body shape). The OpenAI error payload and the PD bootstrap registry's
//! plain-text bodies are protocol-owned and deliberately not unified here.
use std::convert::Infallible;
use axum::{
Json,
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
/// The native error body — the same `{"error": {...}}` object every native
/// path emits, not bare text, which a client parsing JSON chokes on.
pub fn error_value(code: u16, message: &str) -> serde_json::Value {
serde_json::json!({ "error": { "message": message, "code": code } })
}
/// Form an error in the shape the client committed to: unary → `code` plus
/// the JSON `body`; streaming → 200 with one SSE error frame + `[DONE]` (the
/// client is already reading a stream — Python answers in-stream too, from
/// `stream_results()`). The `body` is caller-shaped: native [`error_value`]
/// or the OpenAI error payload.
pub fn error_response(code: StatusCode, body: serde_json::Value, stream: bool) -> Response {
if !stream {
return (code, Json(body)).into_response();
}
sse_error_response(body)
}
/// A 200 SSE response carrying one error frame + `[DONE]` — how a stream the
/// client is already committed to reading reports a failure. Shared by every
/// endpoint family: the native API and the OpenAI
/// frontend's `openai_error_response`.
pub fn sse_error_response(body: serde_json::Value) -> Response {
let frames = [body.to_string(), "[DONE]".to_string()];
Sse::new(futures::stream::iter(
frames.map(|data| Ok::<_, Infallible>(Event::default().data(data))),
))
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
/// Python-parity pins for the native error shapes (`generate_request`):
/// unary errors are a 4xx/5xx with a JSON `{"error": ...}` body; streaming
/// ones are 200 + one SSE error frame + `[DONE]`, because Python answers
/// from inside `stream_results()` once the stream is committed.
#[tokio::test]
async fn error_responses_match_python_shape() {
let unary = error_response(
StatusCode::BAD_REQUEST,
error_value(400, "bad input"),
false,
);
assert_eq!(unary.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(unary.into_body(), 64 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).expect("JSON body");
assert_eq!(v["error"]["message"], "bad input");
assert_eq!(v["error"]["code"], 400);
let streamed = error_response(StatusCode::BAD_REQUEST, error_value(400, "bad input"), true);
assert_eq!(
streamed.status(),
StatusCode::OK,
"the stream itself is 200"
);
let body = axum::body::to_bytes(streamed.into_body(), 64 * 1024)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(
text.contains(r#""code":400"#),
"carries the status in-band: {text}"
);
assert!(
text.trim_end().ends_with("data: [DONE]"),
"terminated: {text}"
);
}
}