Skip to content

Commit 68821c3

Browse files
committed
fix(gateway): keep interrupted SSE streams health-neutral
Terminal-missing HTTP SSE upstream closes still finalize the request and attempt as upstream_stream_error, but no longer demote bridge health or open proxy_stream routing circuits. This prevents interrupted 200 OK streams from exhausting otherwise eligible backends into no_eligible_backend while keeping idle timeouts and explicit upstream failures on the existing failure path.
1 parent b19bf8f commit 68821c3

3 files changed

Lines changed: 108 additions & 0 deletions

File tree

lib/codex_pooler/gateway/runtime/finalization/streaming.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Streaming do
199199
record_health_failure(code, code, context)
200200
end
201201

202+
defp record_stream_failure_health(
203+
:upstream_stream_interrupted,
204+
"upstream_stream_error",
205+
nil,
206+
_headers,
207+
_context
208+
),
209+
do: :ok
210+
202211
defp record_stream_failure_health(reason, code, nil, _headers, context) do
203212
record_health_failure(reason, code, context)
204213
end

test/codex_pooler/gateway/runtime/streaming/stream_lifecycle_test.exs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.StreamLifecycleTest do
1212
alias CodexPooler.Accounting.{Attempt, Request}
1313
alias CodexPooler.FakeUpstream
1414
alias CodexPooler.Gateway.Payloads.RequestOptions
15+
alias CodexPooler.Gateway.Persistence.{BridgeDemotion, RoutingCircuitState}
1516
alias CodexPooler.Gateway.Routing.{BridgeRing, RoutePlanInput}
1617
alias CodexPooler.Gateway.Runtime.Dispatch.{Context, ResponseContext}
18+
alias CodexPooler.Gateway.Runtime.Finalization.Streaming
1719
alias CodexPooler.Gateway.Runtime.Streaming.OpenAIStreamCollector
1820
alias CodexPooler.Gateway.Runtime.Streaming.StreamLifecycle
1921
alias CodexPooler.Gateway.Service
@@ -248,6 +250,56 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.StreamLifecycleTest do
248250
assert_pre_first_stall_finalized!(setup, "partial frame stall")
249251
end
250252

253+
test "terminal-missing upstream SSE close fails request without poisoning route health" do
254+
{setup, _first_upstream, _second_upstream} =
255+
stream_retry_setup(
256+
FakeUpstream.sse_stream([]),
257+
FakeUpstream.sse_stream([])
258+
)
259+
260+
{:ok, auth} = Access.authenticate_authorization_header(setup.authorization)
261+
payload = payload(setup)
262+
request_options = request_options(auth, payload, setup)
263+
264+
assert {:ok, reserved} =
265+
Accounting.reserve(auth, setup.model, payload, %{
266+
endpoint: @endpoint_path,
267+
transport: "http_sse",
268+
correlation_id:
269+
"upstream-stream-interrupted-#{System.unique_integer([:positive])}",
270+
request_metadata: %{}
271+
})
272+
273+
assert {:ok, attempt} = Accounting.create_attempt(reserved.request, setup.assignment)
274+
275+
context =
276+
retry_context(setup, auth, request_options, reserved.request,
277+
candidates: [{setup.assignment, setup.identity}],
278+
attempt: attempt
279+
)
280+
281+
response_context = %ResponseContext{context: context, response: %Req.Response{status: 200}}
282+
283+
assert {:ok, _finalized} =
284+
Streaming.finalize_failure(
285+
"event: response.created\n\n",
286+
:upstream_stream_interrupted,
287+
response_context
288+
)
289+
290+
assert [request] = Repo.all(from(r in Request, where: r.id == ^reserved.request.id))
291+
assert request.status == "failed"
292+
assert request.last_error_code == "upstream_stream_error"
293+
294+
assert [attempt] = Repo.all(from(a in Attempt, where: a.request_id == ^request.id))
295+
assert attempt.status == "failed"
296+
assert attempt.network_error_code == "upstream_stream_error"
297+
assert attempt.response_metadata["error_kind"] == "stream_interrupted"
298+
299+
assert Repo.all(from(d in BridgeDemotion)) == []
300+
assert Repo.all(from(c in RoutingCircuitState)) == []
301+
end
302+
251303
defp retry_context(setup, auth, request_options, request, opts \\ []) do
252304
candidates =
253305
Keyword.get(opts, :candidates, [

test/codex_pooler_web/controllers/runtime/backend_codex_controller_test.exs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,53 @@ defmodule CodexPoolerWeb.Runtime.BackendCodexControllerTest do
12721272
assert_turn_state_not_persisted!(setup, response_turn_state)
12731273
end
12741274

1275+
test "POST /v1/responses terminal-missing SSE close does not poison route health",
1276+
%{conn: conn} do
1277+
upstream =
1278+
start_upstream(
1279+
FakeUpstream.sse_stream(
1280+
[
1281+
{"response.created",
1282+
%{
1283+
"type" => "response.created",
1284+
"response" => %{"id" => "resp_public_terminal_missing"}
1285+
}}
1286+
],
1287+
done: false
1288+
)
1289+
)
1290+
1291+
setup = gateway_setup(upstream)
1292+
1293+
conn =
1294+
conn
1295+
|> auth(setup)
1296+
|> post("/v1/responses", %{
1297+
"model" => setup.model.exposed_model_id,
1298+
"input" => "synthetic public terminal-missing stream request",
1299+
"stream" => true
1300+
})
1301+
1302+
assert conn.status == 200
1303+
assert conn.resp_body =~ "event: response.failed\n"
1304+
assert conn.resp_body =~ ~s("code":"upstream_stream_error")
1305+
1306+
assert [request] = Repo.all(from(r in Request, where: r.pool_id == ^setup.pool.id))
1307+
assert request.endpoint == "/backend-api/codex/responses"
1308+
assert request.transport == "http_sse"
1309+
assert request.status == "failed"
1310+
assert request.last_error_code == "upstream_stream_error"
1311+
1312+
assert [attempt] = Repo.all(from(a in Attempt, where: a.request_id == ^request.id))
1313+
assert attempt.status == "failed"
1314+
assert attempt.upstream_status_code == 200
1315+
assert attempt.network_error_code == "upstream_stream_error"
1316+
assert attempt.response_metadata["error_kind"] == "stream_interrupted"
1317+
1318+
assert Repo.all(from(d in BridgeDemotion)) == []
1319+
assert Repo.all(from(c in RoutingCircuitState)) == []
1320+
end
1321+
12751322
test "POST /backend-api/codex/responses sends trusted Responses Lite marker from selected model metadata",
12761323
%{conn: conn} do
12771324
upstream =

0 commit comments

Comments
 (0)