Skip to content

Prevent stream hangs when SSE headers are missing - #3928

Closed
snoyiatk wants to merge 3 commits into
maximhq:devfrom
snoyiatk:codex/fix-openai-sse-without-content-type
Closed

Prevent stream hangs when SSE headers are missing#3928
snoyiatk wants to merge 3 commits into
maximhq:devfrom
snoyiatk:codex/fix-openai-sse-without-content-type

Conversation

@snoyiatk

@snoyiatk snoyiatk commented May 31, 2026

Copy link
Copy Markdown

Summary

  • preserve OpenAI-compatible streaming readers when upstream responses look like SSE but omit Content-Type: text/event-stream
  • keep non-SSE drain behavior for JSON/non-line-delimited error bodies
  • add regression coverage for both the reader-preserving helper and the legacy response helper

Validation

  • docker run --rm -v /root/ws/ai/ai-gateway/bifrost:/src -w /src/core golang:1.26.3-alpine3.23 sh -lc '/usr/local/go/bin/gofmt -w providers/utils/utils.go providers/openai/openai.go providers/utils/utils_test.go && /usr/local/go/bin/go test ./providers/utils -run "TestDrainNonSSEStream"'\n- docker run --rm -v /root/ws/ai/ai-gateway/bifrost:/src -w /src/core golang:1.26.3-alpine3.23 sh -lc '/usr/local/go/bin/go test ./providers/openai ./providers/utils'\n- manual gateway replay against patched container returned HTTP 200 and event: response.completed for /v1/responses routed to codex-backend\n\n## Notes\n- local request fixtures were not committed because they contain secrets\n

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Enhanced detection and handling of Server-Sent Events in streaming responses, including cases where content-type headers may be missing or incorrect
    • Improved robustness of non-SSE response detection and error handling across multiple streaming operations
  • Tests

    • Added unit tests validating Server-Sent Events detection behavior in edge cases without proper headers

akshaydeo and others added 3 commits May 31, 2026 13:59
## Summary

Removes the redundant changelog header and version label from `cli/changelog.md` and fixes a missing newline at the end of the file.

## Changes

- Removed the `# Bifrost CLI Changelog` heading and `## v0.10.5` version label from the top of the changelog
- Added a trailing newline to the final line of the file to resolve the "no newline at end of file" issue

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Documentation
- [x] Chore/CI

## Affected areas

- [ ] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [x] Docs

## How to test

Verify the changelog renders correctly and that no trailing newline warning appears in diff tooling.

```sh
cat -A cli/changelog.md | tail -5
```

The last line should end with `$` (indicating a proper newline terminator).

## Screenshots/Recordings

N/A

## Breaking changes

- [ ] Yes
- [x] No

## Related issues

N/A

## Security considerations

None.

## Checklist

- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [x] I updated documentation where needed
- [x] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **Documentation**
  * Updated changelog to document CLI command overlay improvements enabling arrow key navigation in tab popups.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Some OpenAI-compatible backends emit valid SSE frames without declaring text/event-stream. The previous non-SSE guard relied only on the response header and drained those streams before the scanner could observe terminal events like response.completed.

This keeps the existing drain behavior for JSON/non-line-delimited error bodies, but lets wrapped streaming readers sniff a small prefix and replay it when the bytes look like SSE frames.

Constraint: Codex backend responses can be valid SSE without Content-Type: text/event-stream

Rejected: Disable the non-SSE drain entirely | it protects scanners from large non-line-delimited error bodies

Confidence: high

Scope-risk: narrow

Directive: Keep stream guards reader-preserving after gzip/idle-timeout wrapping; do not read directly from resp.BodyStream in wrapped streaming paths

Tested: docker go test ./providers/utils -run TestDrainNonSSEStream

Tested: docker go test ./providers/openai ./providers/utils

Co-authored-by: OmX <omx@oh-my-codex.dev>
@CLAassistant

CLAassistant commented May 31, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
0 out of 2 committers have signed the CLA.

❌ Duong M. CUONG
❌ akshaydeo


Duong M. CUONG seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR refactors OpenAI streaming handlers to detect and handle non-SSE responses using a new reader-based drain helper with prefix sniffing. Instead of relying solely on the Content-Type header, the code now reads a small prefix from the stream, checks for SSE markers (data:, event:, :), and only drains if the content is clearly non-SSE. This avoids discarding SSE-like content when the header is missing, while preserving any sniffed prefix for downstream consumption.

Changes

SSE Non-Drain Detection via Reader-Based Sniffing

Layer / File(s) Summary
SSE prefix sniffing and reader-based drain helper
core/providers/utils/utils.go
Introduced looksLikeSSEStreamPrefix to detect SSE markers in a content prefix. Introduced DrainNonSSEStreamReader to read a small prefix, check for SSE-like patterns, and conditionally drain the remaining stream, returning the rewound reader and a drained flag.
Response-level drain refactored to use reader helper
core/providers/utils/utils.go
DrainNonSSEStreamResponse now delegates to DrainNonSSEStreamReader and preserves any sniffed prefix by calling resp.SetBodyStream when the reader has been rewound but not drained.
SSE detection and draining unit tests
core/providers/utils/utils_test.go
Two new tests verify SSE-like payloads without explicit Content-Type: text/event-stream are not drained and remain readable after sniffing.
OpenAI streaming handler migrations to reader-based drain
core/providers/openai/openai.go
Seven OpenAI streaming flows (text completion, chat completion, responses, speech, transcription, image generation, image edit) now call DrainNonSSEStreamReader to detect and handle non-SSE responses, capturing the advanced reader and drained flag before setting error state if draining occurred.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • akshaydeo
  • danpiths

Poem

A rabbit hops through streams so fine,
Sniffing data: and event: signs,
Reader-based wisdom, prefix light,
Seven flows now drain just right! 🐰✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description addresses the summary and changes sections well but is missing several required template sections including Type of change, Affected areas, How to test, Breaking changes, Related issues, Security considerations, and the completion checklist. Add missing template sections: select the change type (Bug fix), mark affected areas (Core/Go and Providers), provide explicit test steps, confirm no breaking changes, link related issues, note security implications, and complete the verification checklist.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: preventing stream hangs when SSE Content-Type headers are missing, which directly relates to the core functionality changes across OpenAI providers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/providers/utils/utils.go`:
- Around line 1024-1039: DrainNonSSEStreamReader currently decides SSE vs
non‑SSE after a single reader.Read which can return a short partial chunk;
instead loop to accumulate reads into buf (using reader.Read into buf[:]) until
you either have enough bytes to reliably call looksLikeSSEStreamPrefix(prefix)
(use the same prefix length checks as looksLikeSSEStreamPrefix expects), hit EOF
or an error, or fill the 512‑byte buffer; once you have a sufficient prefix,
reconstruct the reader with io.MultiReader(bytes.NewReader(prefix), reader) and
only return (reader, false) if looksLikeSSEStreamPrefix(prefix) is true,
otherwise drain and return (reader, true); ensure you still handle readErr !=
nil (and treat non‑EOF errors as draining case) and avoid consuming the stream
prematurely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a761f0cd-ab8f-40bc-828e-55dcc39a55f1

📥 Commits

Reviewing files that changed from the base of the PR and between d4c96b8 and d4b320a.

📒 Files selected for processing (3)
  • core/providers/openai/openai.go
  • core/providers/utils/utils.go
  • core/providers/utils/utils_test.go

Comment on lines +1024 to +1039
var buf [512]byte
n, readErr := reader.Read(buf[:])
if n > 0 {
prefix := append([]byte(nil), buf[:n]...)
reader = io.MultiReader(bytes.NewReader(prefix), reader)
if looksLikeSSEStreamPrefix(prefix) {
return reader, false
}
}
return true

if readErr != nil && readErr != io.EOF {
return reader, true
}

_, _ = io.Copy(io.Discard, reader)
return reader, true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and show the current implementation around the reported lines
rg -n "func DrainNonSSEStreamReader|DrainNonSSEStreamReader\(" core/providers/utils/utils.go
rg -n "looksLikeSSEStreamPrefix|func looksLikeSSEStreamPrefix" core/providers/utils/utils.go

# Print the relevant section (keep it small)
nl -ba core/providers/utils/utils.go | sed -n '980,1100p'

Repository: maximhq/bifrost

Length of output: 393


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show exact code around DrainNonSSEStreamReader and looksLikeSSEStreamPrefix
sed -n '990,1065p' core/providers/utils/utils.go

echo "----"
sed -n '1065,1125p' core/providers/utils/utils.go

echo "----"
# Also show the call site context around line ~1004
sed -n '960,1035p' core/providers/utils/utils.go

Repository: maximhq/bifrost

Length of output: 7439


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the rest of the function
sed -n '1030,1100p' core/providers/utils/utils.go

echo "---- call sites ----"
rg -n "DrainNonSSEStream(Response|Reader)\(" -S core | head -n 50

echo "---- SSE scanner usage ----"
rg -n "bufio\.NewScanner|Scan\(|SplitFunc|scanner" core/providers -S | head -n 50

Repository: maximhq/bifrost

Length of output: 7324


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect call-site logic around one of the DrainNonSSEStreamReader uses
sed -n '520,610p' core/providers/openai/openai.go

echo "----"
sed -n '1080,1145p' core/providers/openai/openai.go

echo "---- tests ----"
sed -n '680,880p' core/providers/utils/utils_test.go

Repository: maximhq/bifrost

Length of output: 12545


Don’t classify SSE from a single short Read in DrainNonSSEStreamReader

core/providers/utils/utils.go DrainNonSSEStreamReader does only one reader.Read(buf[:]) sniff and drains the stream unless looksLikeSSEStreamPrefix(prefix) matches. io.Reader.Read is allowed to return partial data on the first call, so a valid SSE stream (with missing/incorrect Content-Type) can be misclassified and fully drained (e.g., first read yields d / da / eve, so the prefix doesn’t match).

Current tests cover full data:/event: bodies from bytes.NewReader (single-read), but don’t exercise fragmented/short-read behavior.

Suggested direction
 func DrainNonSSEStreamReader(resp *fasthttp.Response, reader io.Reader) (io.Reader, bool) {
 	if resp == nil || reader == nil {
 		return reader, true
 	}
 
 	ct := strings.ToLower(string(resp.Header.ContentType()))
 	if strings.Contains(ct, "text/event-stream") {
 		return reader, false
 	}
 
-	var buf [512]byte
-	n, readErr := reader.Read(buf[:])
-	if n > 0 {
-		prefix := append([]byte(nil), buf[:n]...)
-		reader = io.MultiReader(bytes.NewReader(prefix), reader)
-		if looksLikeSSEStreamPrefix(prefix) {
-			return reader, false
-		}
-	}
-
-	if readErr != nil && readErr != io.EOF {
-		return reader, true
-	}
-
-	_, _ = io.Copy(io.Discard, reader)
-	return reader, true
+	var buf [512]byte
+	prefix := make([]byte, 0, len(buf))
+
+	for len(prefix) < len(buf) {
+		n, readErr := reader.Read(buf[len(prefix):])
+		if n > 0 {
+			prefix = append(prefix, buf[len(prefix):len(prefix)+n]...)
+			if looksLikeSSEStreamPrefix(prefix) {
+				return io.MultiReader(bytes.NewReader(prefix), reader), false
+			}
+			if bytes.ContainsRune(prefix, '\n') {
+				break
+			}
+		}
+		if readErr != nil {
+			reader = io.MultiReader(bytes.NewReader(prefix), reader)
+			if readErr != io.EOF {
+				return reader, true
+			}
+			break
+		}
+	}
+
+	reader = io.MultiReader(bytes.NewReader(prefix), reader)
+	if !bytes.ContainsRune(prefix, '\n') {
+		return reader, false // inconclusive prefix: preserve rather than false-drain
+	}
+
+	_, _ = io.Copy(io.Discard, reader)
+	return reader, true
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/providers/utils/utils.go` around lines 1024 - 1039,
DrainNonSSEStreamReader currently decides SSE vs non‑SSE after a single
reader.Read which can return a short partial chunk; instead loop to accumulate
reads into buf (using reader.Read into buf[:]) until you either have enough
bytes to reliably call looksLikeSSEStreamPrefix(prefix) (use the same prefix
length checks as looksLikeSSEStreamPrefix expects), hit EOF or an error, or fill
the 512‑byte buffer; once you have a sufficient prefix, reconstruct the reader
with io.MultiReader(bytes.NewReader(prefix), reader) and only return (reader,
false) if looksLikeSSEStreamPrefix(prefix) is true, otherwise drain and return
(reader, true); ensure you still handle readErr != nil (and treat non‑EOF errors
as draining case) and avoid consuming the stream prematurely.

@greptile-apps

greptile-apps Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

The core fix is sound: all 7 streaming handlers correctly pass the pre-wrapped reader through the sniff, and the MultiReader reconstruction is watertight. The two flagged items are minor — an unnecessary SetBodyStream in a compat shim with no production callers, and a missing drain-path test for the new function — neither of which affects the happy or error paths in production.

The streaming logic change is well-scoped and the 512-byte sniff + MultiReader pattern is correct. The only concerns are a small behavioral change in the legacy compat shim (SetBodyStream called when it wasn't before, but with no callers harmed) and missing direct test coverage for the non-SSE drain branch of the new function.

core/providers/utils/utils.go — specifically the DrainNonSSEStreamResponse compat shim's unconditional SetBodyStream call

Important Files Changed

Filename Overview
core/providers/utils/utils.go Introduces DrainNonSSEStreamReader that sniffs up to 512 bytes to detect SSE frames even when Content-Type is absent; replaces DrainNonSSEStreamResponse logic. The compat shim now calls SetBodyStream unconditionally for non-drained results, including the text/event-stream case where the reader is unchanged.
core/providers/openai/openai.go All 7 streaming handlers migrated from DrainNonSSEStreamResponse to DrainNonSSEStreamReader, correctly threading the already-wrapped reader (gzip + idle-timeout + cancellation layers) through the sniff call.
core/providers/utils/utils_test.go Adds two tests covering SSE-without-Content-Type for both the new reader-preserving function and the compat response helper. The non-SSE drain path of DrainNonSSEStreamReader (body is JSON/non-SSE) is not directly tested.

Reviews (1): Last reviewed commit: "Prevent stream hangs when SSE headers ar..." | Re-trigger Greptile

Comment on lines 1003 to +1009
func DrainNonSSEStreamResponse(resp *fasthttp.Response) bool {
reader, drained := DrainNonSSEStreamReader(resp, resp.BodyStream())
if !drained && reader != nil {
resp.SetBodyStream(reader, -1)
}
return drained
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Compat shim calls SetBodyStream even when no bytes were sniffed

When Content-Type: text/event-stream is present, DrainNonSSEStreamReader returns the original reader immediately without consuming any bytes. The shim then unconditionally calls resp.SetBodyStream(reader, -1) (since !drained && reader != nil is true), replacing the stream with the same reader but dropping the original known size. The pre-PR DrainNonSSEStreamResponse was a pure check that left the response entirely unmodified for confirmed-SSE streams. The guard should skip the SetBodyStream when the reader is the unmodified original.

Comment on lines +751 to +770
func TestDrainNonSSEStreamReader_SSEWithoutContentTypeDoesNotDrain(t *testing.T) {
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)

body := []byte("event: response.completed\n" +
`data: {"type":"response.completed","sequence_number":1}` + "\n\n")
resp.SetBodyStream(bytes.NewReader(body), len(body))

reader, drained := DrainNonSSEStreamReader(resp, resp.BodyStream())
if drained {
t.Fatal("expected SSE-looking response without content type to remain readable")
}

remaining, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("failed to read SSE body after guard: %v", err)
}
if string(remaining) != string(body) {
t.Fatalf("expected SSE body to remain intact, got %q", string(remaining))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No direct test for DrainNonSSEStreamReader non-SSE drain path

The two new tests only exercise the SSE-detection (returns false) branch of DrainNonSSEStreamReader. The complementary case — a JSON/non-line-delimited body passed directly to DrainNonSSEStreamReader — is not covered. Without it, a future regression in the drain path (e.g., accidentally returning false for a plain JSON body) would go undetected until a stream hang reappears in production.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@snoyiatk

snoyiatk commented Jun 3, 2026

Copy link
Copy Markdown
Author

We can close this since you already merged #3956

@snoyiatk snoyiatk closed this Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants