Skip to content

fix(relay): keep long JSON generation requests alive - #4506

Closed
gaoren002 wants to merge 2 commits into
QuantumNous:mainfrom
gaoren002:pr/image-json-keepalive
Closed

fix(relay): keep long JSON generation requests alive#4506
gaoren002 wants to merge 2 commits into
QuantumNous:mainfrom
gaoren002:pr/image-json-keepalive

Conversation

@gaoren002

@gaoren002 gaoren002 commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

背景

图片、视频、Midjourney 等生成类接口可能在上游处理阶段超过 CDN 或反向代理的空闲超时时间。此时客户端连接可能先被断开,表现为 524、超时或响应体断流,但上游请求实际上可能仍在执行并产生费用。

改动

  • 增加通用 JSON 响应保活 helper。
  • 若 75 秒内上游仍未返回响应头,先发送一次 102 Processing 信息状态码。
  • 后续每 25 秒继续发送 102 Processing,刷新连接空闲时间。
  • 上游响应返回后立即停止保活,再按原逻辑处理最终响应。
  • 保活不写入 JSON body,不提交最终 HTTP 状态码,因此不会污染最终的 2004xx5xx 响应。

适用范围

  • OpenAI 兼容图片接口:/v1/images/generations/v1/images/edits
  • 通用任务提交接口:/v1/video/generations/v1/videos/v1/videos/:video_id/remix/suno/submit/*/kling/v1/videos/*/jimeng 等走 RelayTaskSubmit 的 JSON 提交路径。
  • Midjourney 提交类接口:/mj/submit/*/mj/insight-face/swap/mj/task/:id/image-seed
  • 不改变重试、计费、渠道选择、图片响应内容和错误处理逻辑。
  • 如果部署链路中的代理不转发或不认可 HTTP 1xx 信息响应,最终响应仍保持兼容,只是该代理层可能无法获得保活效果。

测试

go test ./relay -run 'TestJSONKeepalive'
go test ./relay

测试覆盖:

  • 保活会发送 102 Processing 并 flush。
  • 102 Processing 不会覆盖最终 JSON 响应状态码。

Summary by CodeRabbit

  • New Features

    • Added a JSON keepalive that sends periodic heartbeat responses for long-running image and Midjourney-related requests to reduce connection timeouts.
  • Bug Fixes

    • Ensures keepalive is reliably stopped both on request completion and on early exit to prevent stray background activity.
  • Tests

    • Added tests validating keepalive timing, header behavior, repeated informational writes, flushes, and proper shutdown.

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Introduces an unexported JSON keepalive that emits periodic HTTP 102 Processing responses and integrates it into multiple relay handlers (image, mjproxy, relay task) to run during outbound requests, with explicit and deferred stop semantics to ensure cleanup.

Changes

Cohort / File(s) Summary
JSON Keepalive Utility
relay/json_keepalive.go
New unexported jsonKeepalive and startJSONKeepalive implementing a background ticker that emits HTTP 102 Processing writes, snapshots/restores headers, supports stop/done signaling, and reports if any write occurred.
Keepalive Tests
relay/json_keepalive_test.go
New tests and a concurrency-safe jsonKeepaliveRecorder verifying informational 102 writes, header values, flush behavior, repeated ticks, and stop semantics.
Image handler integration
relay/image_handler.go
Starts keepalive before adaptor.DoRequest, defers keepalive.stop(), and explicitly stops the keepalive immediately after the request returns.
MJ proxy handlers
relay/mjproxy_handler.go
Wraps service.DoMidjourneyHttpRequest calls in RelaySwapFace, RelayMidjourneyTaskImageSeed, and RelayMidjourneySubmit with start/defer/explicit stop of the JSON keepalive.
Relay task handler
relay/relay_task.go
Starts keepalive around adaptor.DoRequest in RelayTaskSubmit, defers stop, and explicitly stops after the request completes.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Handler as HTTP Handler
    participant Keepalive as JSON Keepalive
    participant Remote as Remote Service
    participant RW as ResponseWriter

    Client->>Handler: Long HTTP request
    Handler->>Keepalive: startJSONKeepalive(ctx, initialDelay, interval)
    Keepalive->>RW: snapshot headers, set processing headers
    Handler->>Remote: DoRequest / DoMidjourneyHttpRequest
    par Keepalive ticker vs Remote request
        Keepalive->>RW: Write 102 Processing, Flush
        Remote-->>Remote: remote processing
    end
    Remote-->>Handler: Response (or error)
    Handler->>Keepalive: keepalive.stop()
    Keepalive->>Keepalive: signal stop, wait done
    Handler->>RW: Write final status/body
    Handler-->>Client: Final HTTP response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • creamlike1024

"I nibble lines and guard the stream,
I tap 102 to keep the dream,
Flushing beats so long tasks don't drop,
A rabbit's hop keeps traffic up! 🐇"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding a JSON keepalive mechanism to prevent timeouts for long-running requests in the relay system.
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

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

🧹 Nitpick comments (1)
relay/json_keepalive_test.go (1)

71-108: Cover the repeated-tick path and the post-stop() quiet period.

The current tests prove that one 102 Processing can be emitted and that the final JSON status survives, but they do not lock in the two behaviors this loop depends on most: sending another 102 on the next interval and emitting nothing after stop() returns. A regression in either branch would still pass here.

🧪 Suggested additions
+func TestJSONKeepaliveRepeatsUntilStopped(t *testing.T) {
+	rec := newJSONKeepaliveRecorder()
+	c := newJSONKeepaliveTestContext(rec)
+
+	keepalive := startJSONKeepalive(c, 5*time.Millisecond, 5*time.Millisecond)
+	require.NotNil(t, keepalive)
+	require.Eventually(t, func() bool {
+		informational, _, _, _ := rec.snapshot()
+		return len(informational) >= 2
+	}, time.Second, 5*time.Millisecond)
+	keepalive.stop()
+}
+
+func TestJSONKeepaliveStopPreventsFurtherWrites(t *testing.T) {
+	rec := newJSONKeepaliveRecorder()
+	c := newJSONKeepaliveTestContext(rec)
+
+	keepalive := startJSONKeepalive(c, 5*time.Millisecond, 5*time.Millisecond)
+	require.NotNil(t, keepalive)
+	require.Eventually(t, func() bool {
+		return keepalive.wasWritten()
+	}, time.Second, 5*time.Millisecond)
+
+	before, _, _, _ := rec.snapshot()
+	keepalive.stop()
+	time.Sleep(20 * time.Millisecond)
+	after, _, _, _ := rec.snapshot()
+
+	require.Len(t, after, len(before))
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/json_keepalive_test.go` around lines 71 - 108, Extend the existing
tests (TestJSONKeepaliveSendsInformationalProcessing and/or
TestJSONKeepalivePreservesFinalJSONStatus) to exercise the repeated-tick and
post-stop quiet-period paths: after confirming the first 102 was written (using
startJSONKeepalive and rec.snapshot / keepalive.wasWritten), wait for one more
tick interval plus a small margin and assert a second informational 102 was
emitted (e.g., len(informational) increased by 1 or informational[1] ==
http.StatusProcessing), then call keepalive.stop(), wait for a short quiet
period (greater than the write interval) and assert no further writes occur
(snapshot shows same flush/body/status counts and no new informational codes
after stop returned). Use the existing helpers newJSONKeepaliveRecorder,
newJSONKeepaliveTestContext, startJSONKeepalive, keepalive.stop, rec.snapshot
and wasWritten to locate and implement these assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@relay/json_keepalive.go`:
- Around line 30-32: The three headers set by the c.Header calls (Content-Type,
Cache-Control, X-Accel-Buffering) must only be attached when you actually emit
the 102 keepalive; move those c.Header(...) calls out of the fast-path and into
the branch or helper that writes the interim 102 response (the code that
performs the keepalive write/emit), e.g., the function or block that sends the
102 keepalive (where you currently call WriteHeader or send the keepalive byte).
Remove the global header-setting from the initial path so fast responses (and
other handlers like relay/image_handler.go) do not inherit application/json or
X-Accel-Buffering: no. Ensure headers are set immediately before sending the 102
so they accompany only the keepalive.

---

Nitpick comments:
In `@relay/json_keepalive_test.go`:
- Around line 71-108: Extend the existing tests
(TestJSONKeepaliveSendsInformationalProcessing and/or
TestJSONKeepalivePreservesFinalJSONStatus) to exercise the repeated-tick and
post-stop quiet-period paths: after confirming the first 102 was written (using
startJSONKeepalive and rec.snapshot / keepalive.wasWritten), wait for one more
tick interval plus a small margin and assert a second informational 102 was
emitted (e.g., len(informational) increased by 1 or informational[1] ==
http.StatusProcessing), then call keepalive.stop(), wait for a short quiet
period (greater than the write interval) and assert no further writes occur
(snapshot shows same flush/body/status counts and no new informational codes
after stop returned). Use the existing helpers newJSONKeepaliveRecorder,
newJSONKeepaliveTestContext, startJSONKeepalive, keepalive.stop, rec.snapshot
and wasWritten to locate and implement these assertions.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b1c2f57-09f7-45e2-8794-3e1e2107bb33

📥 Commits

Reviewing files that changed from the base of the PR and between 9f8a4ec and 0cf9eba.

📒 Files selected for processing (5)
  • relay/image_handler.go
  • relay/json_keepalive.go
  • relay/json_keepalive_test.go
  • relay/mjproxy_handler.go
  • relay/relay_task.go

Comment thread relay/json_keepalive.go Outdated
@gaoren002

Copy link
Copy Markdown
Contributor Author

@Calcium-Ion

@gaoren002
gaoren002 force-pushed the pr/image-json-keepalive branch from 3a7f316 to d55f048 Compare April 28, 2026 08:06

@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.

🧹 Nitpick comments (2)
relay/json_keepalive_test.go (1)

28-30: Consider adding mutex protection to Header() for race-free testing.

The Header() method returns r.header without acquiring the lock. During the Eventually polling loops, the keepalive goroutine may be modifying the header map concurrently with snapshot() cloning it. While the final assertions occur after stop() (blocking until goroutine exit), the race detector may flag this during intermediate polling.

🔧 Suggested fix
 func (r *jsonKeepaliveRecorder) Header() http.Header {
+	r.mu.Lock()
+	defer r.mu.Unlock()
 	return r.header
 }

Alternatively, since Header() is called by the production code via the unwrapped writer and needs to return the same map instance for modifications, you could leave it as-is and accept the benign race in test code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/json_keepalive_test.go` around lines 28 - 30, The Header() method of
jsonKeepaliveRecorder returns r.header without locking, causing a data race
during Eventually polling; fix this by acquiring the recorder's mutex before
returning the header (i.e., lock the same mutex used by snapshot()/stop() inside
jsonKeepaliveRecorder.Header()), then return r.header and unlock, ensuring
Header() is race-free during concurrent header cloning and modifications in the
keepalive goroutine.
relay/json_keepalive.go (1)

12-15: Consider documenting the timeout constants' rationale.

The 75-second initial delay and 25-second interval appear well-chosen (75s is under typical proxy timeouts like nginx's 60s default plus buffer; 25s provides multiple keepalives before most 60s idle timeouts). A brief comment explaining this would aid future maintainability.

📝 Suggested documentation
 const (
+	// jsonKeepaliveInitialDelay: wait before first 102; set below common proxy
+	// read-timeout defaults (e.g., nginx 60s) plus margin for slow cold-starts.
 	jsonKeepaliveInitialDelay = 75 * time.Second
+	// jsonKeepaliveInterval: subsequent 102s to refresh proxy/CDN idle timers;
+	// 25s keeps connections alive under most 60s idle-timeout configurations.
 	jsonKeepaliveInterval     = 25 * time.Second
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/json_keepalive.go` around lines 12 - 15, Add a brief comment above the
jsonKeepaliveInitialDelay and jsonKeepaliveInterval constants explaining the
rationale for their values (e.g., 75s initial delay chosen to sit just
under/around common proxy/idle timeouts with buffer, and 25s interval to ensure
multiple keepalives within a typical 60s idle window); update the comment near
the constants jsonKeepaliveInitialDelay and jsonKeepaliveInterval in
json_keepalive.go so future readers understand why these specific timeouts were
chosen.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@relay/json_keepalive_test.go`:
- Around line 28-30: The Header() method of jsonKeepaliveRecorder returns
r.header without locking, causing a data race during Eventually polling; fix
this by acquiring the recorder's mutex before returning the header (i.e., lock
the same mutex used by snapshot()/stop() inside jsonKeepaliveRecorder.Header()),
then return r.header and unlock, ensuring Header() is race-free during
concurrent header cloning and modifications in the keepalive goroutine.

In `@relay/json_keepalive.go`:
- Around line 12-15: Add a brief comment above the jsonKeepaliveInitialDelay and
jsonKeepaliveInterval constants explaining the rationale for their values (e.g.,
75s initial delay chosen to sit just under/around common proxy/idle timeouts
with buffer, and 25s interval to ensure multiple keepalives within a typical 60s
idle window); update the comment near the constants jsonKeepaliveInitialDelay
and jsonKeepaliveInterval in json_keepalive.go so future readers understand why
these specific timeouts were chosen.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: edc7d3ae-2fa5-46ba-b220-c954c4ce9950

📥 Commits

Reviewing files that changed from the base of the PR and between 3a7f316 and d55f048.

📒 Files selected for processing (5)
  • relay/image_handler.go
  • relay/json_keepalive.go
  • relay/json_keepalive_test.go
  • relay/mjproxy_handler.go
  • relay/relay_task.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • relay/image_handler.go
  • relay/mjproxy_handler.go

@gaoren002

Copy link
Copy Markdown
Contributor Author

需要指出,这个不一定总有效,我只在cf直接代理时测试生效 cf 隧道好像不行

@gaoren002

Copy link
Copy Markdown
Contributor Author

属于不稳定的解决方案,应该用流来解决此问题

@gaoren002 gaoren002 closed this Apr 28, 2026
@gaoren002
gaoren002 deleted the pr/image-json-keepalive branch June 8, 2026 13:10
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.

1 participant