11 KiB
author, created_at, purpose
| author | created_at | purpose |
|---|---|---|
| AI Assistant | 2026-07-22 | 修复 H5 模式下 stream 请求 60 秒超时问题,采用 fetch + ReadableStream + AbortController 方案 |
H5 模式 Stream 超时修复设计
问题概述
现象
在小程序 H5 模式下(http://localhost:5284),"心愿实现"页面的 stream 请求在约 60 秒后超时,浏览器 DevTools Network 面板显示状态为 (failed)。用户无法收到上游服务约 67 秒后产生的 clarification_card 事件。
根本原因
- H5 模式下
uni.request使用浏览器原生fetchAPI - 浏览器
fetch不支持自定义超时参数,即使设置了timeout: 300000也无效 - 浏览器对
fetch连接有约 60 秒的默认超时 - 上游服务响应延迟:从
status事件到clarification_card事件约需 67 秒 - 后果:前端连接在 60 秒时关闭,服务器在 67 秒尝试发送时遇到
Broken pipe
服务器日志证据
22:09:37 - 上游 status 事件收到并成功转发
22:10:44 - 上游 clarification_card 事件收到
22:10:44 - java.io.IOException: Broken pipe(前端连接已断开)
代码位置
文件: mini-program/src/services/shortNovel.js
问题代码片段(第 16-55 行):
export const startNovelStream = ({ query, onEvent, onError }) => {
const task = uni.request({
// ...
enableChunked: true,
timeout: 300000, // ← H5 模式下无效
// ...
})
}
设计目标
- H5 模式正确接收延迟事件:支持 300 秒超时,覆盖上游 67 秒延迟场景
- 保持现有功能:不破坏小程序 mp-weixin 模式的现有逻辑
- 完全兼容性:
onEvent、onError回调接口不变,调用方无需修改 - 真正流式读取:避免
responseText累积导致的内存问题
技术方案
方案 A:H5 模式使用原生 fetch + ReadableStream(已选定)
核心思路:在 H5 环境下绕过 uni.request,直接使用浏览器原生的 fetch API + response.body.getReader() 读取流,并通过 AbortController 设置自定义超时。
架构设计
双路径策略:
| 环境 | 实现方式 | 原因 |
|---|---|---|
| H5(浏览器) | fetch + ReadableStream + AbortController |
完全控制超时,支持真正流式读取 |
| mp-weixin(小程序) | uni.request + enableChunked |
小程序原生支持,无需修改 |
环境检测:
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
文件变更结构
修改的文件:
mini-program/src/services/shortNovel.js- 添加 H5 路径实现
不修改的文件:
mini-program/src/pages/main/ScriptView.vue- 调用接口完全兼容server/**- 后端无需任何修改nginx.conf- 服务端配置无需修改
实现细节
H5 核心实现
/**
* H5 环境 SSE 流式读取
* 使用浏览器原生 fetch + ReadableStream + AbortController
*/
function h5NovelStream(url, body, onEvent, onError) {
// 1. 创建 AbortController 用于超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 300000)
// 2. 发起 fetch 请求
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
body: JSON.stringify(body),
signal: controller.signal
}).then(response => {
// 3. 检查响应状态
if (!response.ok) {
clearTimeout(timeoutId)
onError?.(`请求失败: HTTP ${response.status}`)
return
}
// 4. 获取 ReadableStream 读取器
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 5. 循环读取流数据
function pump() {
return reader.read().then(({ done, value }) => {
if (done) {
clearTimeout(timeoutId)
// 处理缓冲区残留数据
if (buffer.trim()) h5ConsumeSseText(buffer, onEvent, onError)
return
}
// 6. 解码二进制块为文本
buffer += decoder.decode(value, { stream: true })
// 7. 解析完整的事件(按 \n\n 分隔)
const events = buffer.split('\n\n')
buffer = events.pop() // 最后一个可能不完整,保留到下次
for (const event of events) {
h5ConsumeSseText(event, onEvent, onError)
}
// 8. 继续读取
return pump()
})
}
return pump()
}).catch(err => {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
onError?.('请求超时(300秒)')
} else {
onError?.(err.message || '网络请求失败')
}
})
// 9. 返回 abort 接口
return {
abort: () => {
clearTimeout(timeoutId)
controller.abort()
}
}
}
SSE 事件解析(H5 专用)
/**
* 解析单个 SSE 事件块
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
*/
function h5ConsumeSseText(text, onEvent, onError) {
const lines = text.split('\n')
let dataBuffer = ''
for (const line of lines) {
if (line.startsWith('data:')) {
dataBuffer += line.slice(5).trim()
} else if (line === '' && dataBuffer) {
// 空行表示事件结束
const dataStr = dataBuffer
dataBuffer = ''
if (dataStr === '[DONE]') return
try {
const event = JSON.parse(dataStr)
onEvent?.(event)
} catch (e) {
onError?.(`SSE 解析失败: ${e.message}`)
}
}
}
}
集成入口
修改 startNovelStream 和 followupStream,在入口处根据环境分发:
// 环境检测:H5 模式下存在 window 对象
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
export const startNovelStream = ({ query, onEvent, onError }) => {
if (isH5) {
return h5NovelStream(
`${getApiBaseUrl()}/shortNovel/stream`,
{ query },
onEvent,
onError
)
}
// 小程序环境:保持原有 uni.request 逻辑
let chunkProcessed = false
const task = uni.request({
url: `${getApiBaseUrl()}/shortNovel/stream`,
method: 'POST',
data: { query },
header: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
enableChunked: true,
timeout: 300000,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
return
}
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onEvent, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '网络请求失败')
}
})
task?.onChunkReceived?.((res) => {
chunkProcessed = true
try {
const text = decodeChunk(res.data)
consumeSseText(text, onEvent, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
return task
}
followupStream 采用完全相同的改造模式,在函数开头添加 H5 分支判断。
错误处理策略
| 错误类型 | 处理方式 | 用户提示 |
|---|---|---|
| 网络断开 | 触发 controller.abort() |
"网络连接中断" |
| 超时(300秒) | AbortController 自动触发 |
"请求超时(300秒)" |
| HTTP 4xx/5xx | 检查 response.ok |
显示后端返回的错误信息 |
| SSE 格式错误 | try-catch 包裹 JSON.parse |
"SSE 解析失败"(不中断流) |
| Reader 读取异常 | 在 pump() 内部捕获 |
触发 onError 回调 |
关键设计决策
- 超时时间统一 300 秒:与小程序环境的
uni.request timeout保持一致 - 事件回调接口不变:
onEvent和onError接口完全兼容,调用方无需修改 - 返回值接口对齐:H5 实现返回
{ abort },与 uni.request 任务对象的abort方法对齐 - 不修改后端:纯前端修改,无需重新部署后端服务
- 流式读取而非累积:使用
ReadableStream而非responseText,避免内存累积问题
测试验证
测试用例 1:H5 模式正常生成
- 启动 H5 开发服务器:
cd mini-program && npm run dev:h5 - 访问
http://localhost:5284 - 进入"心愿实现"页面
- 输入心愿文本,触发澄清卡片
- 验证:
- 浏览器 Network 面板显示 stream 请求 200 状态,未超时
- 澄清卡片正常显示
- 完成整个流程后小说正常生成
测试用例 2:小程序模式不受影响
- 构建小程序:
npm run build:mp-weixin - 在微信开发者工具中运行
- 触发完整的小说生成流程
- 验证:原有
uni.request路径正常工作
测试用例 3:超时场景验证
- 修改后端人为延迟 350 秒返回
- 触发 H5 模式 stream 请求
- 验证:300 秒后前端收到 "请求超时(300秒)" 错误提示
测试用例 4:错误处理
- 断网后触发请求
- 验证:收到 "网络连接中断" 错误
- 恢复网络后重试
- 验证:请求成功
验证通过标准
- ✅ H5 模式 stream 请求在 67 秒后才到达的事件能正常接收
- ✅ 浏览器 Network 面板不再显示
(failed)状态 - ✅ 小程序模式完全不受影响
- ✅ 错误处理覆盖所有边界场景
- ✅ 服务器日志中
Broken pipe错误消失
风险评估
风险 1:浏览器兼容性
可能性:低
说明:fetch、ReadableStream、AbortController 在所有现代浏览器(Chrome 76+、Firefox 71+、Safari 14.1+)中支持。我们假设用户使用现代浏览器。
缓解措施:通过 typeof window.fetch === 'function' 检测,对不支持的环境降级到原有逻辑或显示明确错误。
风险 2:内存累积
可能性:低
说明:如果服务端发送数据过快,可能导致缓冲区积累。
缓解措施:使用 TextDecoder 的 stream: true 模式,并及时清空已处理的 buffer。
风险 3:SSE 格式解析错误
可能性:中
说明:不同服务端实现的 SSE 格式可能有细微差异(如 \r\n vs \n、注释行等)。
缓解措施:使用 dataBuffer 累积 data: 行,直到遇到空行才解析,符合 SSE 规范。
完成标准
- ✅
shortNovel.js添加 H5 路径实现 - ✅
startNovelStream和followupStream根据环境自动分发 - ✅ H5 模式通过浏览器验证,stream 请求不再超时
- ✅ 小程序模式通过验证,未受影响
- ✅ 服务器日志中
Broken pipe错误消失 - ✅ 修改提交到 git
后续优化建议
- 统一超时配置:将超时时间(300 秒)提取为常量
- 添加重试机制:网络断开后自动重试
- 心跳检测:定期发送心跳包检测连接活性
- 性能监控:记录流式读取的延迟和吞吐量