Files
happy-life-star/docs/superpowers/specs/2026-07-22-stream-timeout-fix-design.md
T

11 KiB
Raw Blame History

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 事件。

根本原因

  1. H5 模式下 uni.request 使用浏览器原生 fetch API
  2. 浏览器 fetch 不支持自定义超时参数,即使设置了 timeout: 300000 也无效
  3. 浏览器对 fetch 连接有约 60 秒的默认超时
  4. 上游服务响应延迟:从 status 事件到 clarification_card 事件约需 67 秒
  5. 后果:前端连接在 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 模式下无效
    // ...
  })
}

设计目标

  1. H5 模式正确接收延迟事件:支持 300 秒超时,覆盖上游 67 秒延迟场景
  2. 保持现有功能:不破坏小程序 mp-weixin 模式的现有逻辑
  3. 完全兼容性onEventonError 回调接口不变,调用方无需修改
  4. 真正流式读取:避免 responseText 累积导致的内存问题

技术方案

方案 AH5 模式使用原生 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'

文件变更结构

修改的文件

  1. 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}`)
      }
    }
  }
}

集成入口

修改 startNovelStreamfollowupStream,在入口处根据环境分发:

// 环境检测: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 回调

关键设计决策

  1. 超时时间统一 300 秒:与小程序环境的 uni.request timeout 保持一致
  2. 事件回调接口不变onEventonError 接口完全兼容,调用方无需修改
  3. 返回值接口对齐H5 实现返回 { abort },与 uni.request 任务对象的 abort 方法对齐
  4. 不修改后端:纯前端修改,无需重新部署后端服务
  5. 流式读取而非累积:使用 ReadableStream 而非 responseText,避免内存累积问题

测试验证

测试用例 1H5 模式正常生成

  1. 启动 H5 开发服务器:cd mini-program && npm run dev:h5
  2. 访问 http://localhost:5284
  3. 进入"心愿实现"页面
  4. 输入心愿文本,触发澄清卡片
  5. 验证
    • 浏览器 Network 面板显示 stream 请求 200 状态,未超时
    • 澄清卡片正常显示
    • 完成整个流程后小说正常生成

测试用例 2:小程序模式不受影响

  1. 构建小程序:npm run build:mp-weixin
  2. 在微信开发者工具中运行
  3. 触发完整的小说生成流程
  4. 验证:原有 uni.request 路径正常工作

测试用例 3:超时场景验证

  1. 修改后端人为延迟 350 秒返回
  2. 触发 H5 模式 stream 请求
  3. 验证:300 秒后前端收到 "请求超时(300秒)" 错误提示

测试用例 4:错误处理

  1. 断网后触发请求
  2. 验证:收到 "网络连接中断" 错误
  3. 恢复网络后重试
  4. 验证:请求成功

验证通过标准

  • H5 模式 stream 请求在 67 秒后才到达的事件能正常接收
  • 浏览器 Network 面板不再显示 (failed) 状态
  • 小程序模式完全不受影响
  • 错误处理覆盖所有边界场景
  • 服务器日志中 Broken pipe 错误消失

风险评估

风险 1:浏览器兼容性

可能性:低

说明fetchReadableStreamAbortController 在所有现代浏览器(Chrome 76+、Firefox 71+、Safari 14.1+)中支持。我们假设用户使用现代浏览器。

缓解措施:通过 typeof window.fetch === 'function' 检测,对不支持的环境降级到原有逻辑或显示明确错误。

风险 2:内存累积

可能性:低

说明:如果服务端发送数据过快,可能导致缓冲区积累。

缓解措施:使用 TextDecoderstream: true 模式,并及时清空已处理的 buffer。

风险 3SSE 格式解析错误

可能性:中

说明:不同服务端实现的 SSE 格式可能有细微差异(如 \r\n vs \n、注释行等)。

缓解措施:使用 dataBuffer 累积 data: 行,直到遇到空行才解析,符合 SSE 规范。

完成标准

  1. shortNovel.js 添加 H5 路径实现
  2. startNovelStreamfollowupStream 根据环境自动分发
  3. H5 模式通过浏览器验证,stream 请求不再超时
  4. 小程序模式通过验证,未受影响
  5. 服务器日志中 Broken pipe 错误消失
  6. 修改提交到 git

后续优化建议

  1. 统一超时配置:将超时时间(300 秒)提取为常量
  2. 添加重试机制:网络断开后自动重试
  3. 心跳检测:定期发送心跳包检测连接活性
  4. 性能监控:记录流式读取的延迟和吞吐量