
1. EventSource 的重连不是“自动续杯”而是带规则的生存博弈很多人第一次在控制台看到readyState: 0然后瞬间跳成0 → 0 → 0或者收到onerror回调却没触发重连第一反应是“这玩意儿坏了”——其实它没坏只是你没读懂它的生存协议。EventSource 的重连机制根本不是浏览器帮你“默默兜底”的温柔设计而是一套写死在规范里的、有明确触发条件、固定退避策略、且完全不告诉你当前处于第几次重试的状态机契约。关键词EventSource、retry、重连、readyState、onerror全部指向这个核心它不承诺连接成功只承诺按规则尝试它不暴露重试计数但把重试逻辑刻进了底层它用429 Too Many Requests这类状态码直接击穿你的预期逼你从“它该重连”转向“我必须接管重连”。我最早在做一个实时日志看板时踩过这个坑。后端用 Node.js Express 流式返回 SSE 数据前端用new EventSource(/logs)接入。测试时一切正常上线后用户反馈“日志突然卡住刷新才恢复”。抓包发现网络抖动导致某次请求返回了429之后 EventSource 就彻底静默了——onerror被调用了三次readyState始终卡在0再无后续动作。查 MDN 文档才明白EventSource 只对网络层失败如 DNS 解析失败、TCP 连接中断、TLS 握手失败执行自动重连对 HTTP 层错误如 4xx/5xx默认不重连除非服务端显式发送retry:字段并配合event: error或空行触发重试计时器重置。而429正属于 HTTP 层错误浏览器认为“服务端明确拒绝了这次请求”于是放弃重试。这和你直觉里“断了就重连”的认知完全相悖。更隐蔽的是retry字段的生效有严格上下文它只对紧随其后的事件流生效且仅影响下一次重连间隔而非全局重试策略。这意味着如果你的服务端在每次响应中都动态调整retry值比如根据负载返回retry: 10000EventSource 会老实照做但如果你漏发了一次retry它就会回退到默认的 3 秒。这种“契约式依赖”让调试变得极其反直觉——问题往往不出在客户端代码而出在服务端响应头或事件格式的毫厘之差。提示readyState是唯一能被 JS 主动读取的状态标识但它只有三个值0CLOSED、1OPENING、2OPEN。它永远不会显示“正在重连中”或“重试第2次”。你看到readyState 0只代表连接已关闭至于是主动关闭、网络中断还是服务端返回了非 200 响应EventSource 不会告诉你。所有关于“重连进度”的判断必须通过onerror触发频率、时间戳、以及服务端是否返回了retry字段来交叉验证。2. 重连触发的三重门什么情况真重连什么情况直接放弃EventSource 的重连决策不是单一条件判断而是由网络层状态、HTTP 响应码、服务端事件指令三重门共同把关。绝大多数人只盯着onerror回调却忽略了前两道门早已决定了第三道门是否开启。我们逐层拆解这三重门的通行规则2.1 第一重门网络层失败 —— 唯一 guaranteed 重连的场景这是 EventSource 规范中唯一明确保证会触发重连的情况。当底层 TCP 连接因以下原因中断时浏览器会立即启动重连流程DNS 查询失败net::ERR_NAME_NOT_RESOLVEDTCP 连接超时net::ERR_CONNECTION_TIMED_OUTTLS 握手失败net::ERR_SSL_PROTOCOL_ERROR连接被主动拒绝net::ERR_CONNECTION_REFUSED网络接口断开如 Wi-Fi 切换、飞行模式开启此时onerror会被调用readyState变为0浏览器无视任何服务端响应强制进入重连队列。重连间隔由retry字段决定若服务端在上一次有效响应中发送了retry: 5000则等待 5 秒后发起下一次连接若未发送则使用默认的 3 秒。这个过程完全由浏览器内核控制JS 无法干预重连时机只能监听onerror和onopen。我曾在线上环境遇到一个典型案例CDN 节点异常导致部分用户 DNS 解析缓慢EventSource在OPENING状态卡住超过 30 秒后触发超时onerror被调用随后按默认 3 秒重试。但用户感知是“页面卡顿 30 秒后突然恢复”因为onopen回调直到第二次重连成功才触发。解决方案不是改前端而是要求 CDN 运维优化 DNS TTL 和健康检查将首屏连接失败率压到 0.1% 以下——这印证了第一重门的问题本质是基础设施层问题前端能做的只有监控和降级。2.2 第二重门HTTP 响应码 —— 默认放弃除非你亲手递钥匙当连接建立成功但服务端返回了非200 OK的 HTTP 状态码时EventSource 的行为是静默放弃永不重连。这是最常被误解的点。401 Unauthorized、403 Forbidden、429 Too Many Requests、500 Internal Server Error、503 Service Unavailable……所有这些状态码只要出现在响应头中EventSource 就会立即关闭连接设置readyState 0调用onerror然后彻底停止后续所有重试动作。它不会像fetch()那样给你 retry 机会也不会像 Axios 那样允许配置retry次数。为什么这样设计规范制定者的原意是HTTP 状态码代表服务端的明确语义。429意味着“你请求太频繁请稍后再试”这是一个需要客户端理解业务逻辑并主动退避的信号401意味着“你没登录”需要跳转登录页而非盲目重连。EventSource 选择将语义解释权完全交给开发者而非替你做决策。但现实很骨感。热搜词里反复出现的exceeded retry limit, last status: 429 too many requests恰恰暴露了这个设计的痛点当后端限流策略激进比如每分钟只允许 10 次请求而前端又未做请求节流EventSource 就会在短时间内收到多个429响应触发多次onerror最终因“重试次数过多”被浏览器判定为失败。注意这里的 “exceeded retry limit” 并非 EventSource 自身的重试计数器溢出而是浏览器网络栈对同一 URL 的并发请求失败次数达到阈值后的保护性熔断。Chrome 的实际阈值约为 5 次连续失败具体值因版本而异一旦触发后续对该 URL 的所有请求包括新的 EventSource 实例都会被暂时拦截。注意onerror回调本身不携带任何错误详情。你无法在回调函数里拿到status: 429或statusText。唯一能获取 HTTP 状态码的方式是在服务端响应头中添加自定义字段如X-Request-Status: 429并在onopen后通过XMLHttpRequest或fetch主动探测一次但这违背了 SSE 的流式设计初衷。更务实的做法是在服务端记录所有返回429的请求 ID并与前端上报的onerror时间戳做关联分析。2.3 第三重门服务端事件指令 ——retry字段的精确制导这是唯一能让 EventSource 在 HTTP 错误后“起死回生”的方式也是重连机制中最精妙的设计。当服务端在事件流中发送retry:字段时它不仅设置了下一次重连的间隔更向浏览器传递了一个关键信号“本次连接虽结束但请按此规则继续尝试”。retry字段必须满足三个条件才能生效位置严格必须出现在事件流的开头或某个事件块的开头且独立成行即前后都是换行符格式精准retry: 5000冒号后必须有空格数值单位为毫秒不能带单位符号上下文绑定它只对紧随其后的第一个事件生效。如果retry: 5000后面跟着data: hello\n\n那么这个retry值就作用于hello事件如果后面是空行\n\n则作用于下一个事件块。我曾用 Python Flask 实现过一个动态重试服务app.route(/stream) def stream(): def event_stream(): yield retry: 1000\n\n # 首次连接失败后等1秒重试 yield event: connect\ndata: initial\n\n for i in range(10): if i 3: # 模拟第4次推送时服务端过载返回429 yield retry: 5000\n\n # 告诉浏览器下次重连等5秒 yield event: error\ndata: overload\n\n break yield fdata: message-{i}\n\n time.sleep(1) return Response(event_stream(), mimetypetext/event-stream)前端监听到event: error后知道服务端主动告知了异常结合retry: 5000就能准确预判下一次连接将在 5 秒后发起。这种“服务端主导、客户端协同”的模式比纯前端轮询或指数退避更精准、更省资源。3. readyState 的幻觉与 onerror 的沉默如何真正掌握连接状态readyState和onerror是开发者感知 EventSource 状态的唯二公开接口但它们提供的信息远比表面看起来更模糊、更具误导性。很多线上故障的根因正是源于对这两个 API 的过度信任。3.1 readyState一个只有“开关”没有“刻度”的仪表盘readyState的三个值0/1/2看似简单实则隐藏着巨大的状态盲区0CLOSED连接已关闭。但关闭原因未知——可能是你调用了eventSource.close()也可能是网络中断也可能是服务端返回了429还可能是浏览器内存不足主动回收。你无法区分。1OPENING连接正在建立中。但这个状态可能持续任意长时间——DNS 查询、TCP 握手、TLS 协商、服务端处理任何一个环节卡住readyState都会卡在1。而onerror只有在超时或失败后才触发期间你完全无法得知卡在哪一步。2OPEN连接已建立数据正在流式接收。但这不保证数据一定在流动——服务端可能因 GC 暂停、IO 阻塞、或故意发送空行维持连接此时readyState仍是2但你收不到任何message事件。我在一个金融行情系统中遇到过经典问题readyState 2但message事件停止触发超过 30 秒。排查发现服务端为了维持长连接在无新行情时每 25 秒发送一次:\n\n注释行但某次网络抖动导致这个注释行被截断变成了:\n缺少末尾换行EventSource 解析器将其视为不完整事件而丢弃同时未触发任何错误回调。结果就是连接“活着”但数据“死了”。解决方案是在前端增加心跳检测记录最后一次message事件的时间戳若超过 45 秒无更新则主动close()并新建EventSource实例。提示不要用readyState 2作为“连接健康”的唯一判断标准。它只能证明“连接通道存在”不能证明“数据通道畅通”。真正的健康检查必须结合业务数据到达时间、事件解析成功率、以及服务端心跳响应。3.2 onerror一个只报火警不报火源的报警器onerror回调的签名是function(event) {}但event对象在所有主流浏览器中都是一个空对象{}不包含type、target、error等任何有用属性。它唯一的功能就是告诉你“出事了快看看” 至于出了什么事、在哪出的、怎么出的一概不知。更糟的是onerror的触发频率和时机极不稳定对网络层失败它通常在连接超时后触发一次对 HTTP 错误它在收到响应头后立即触发但对某些边缘情况如服务端发送了非法 UTF-8 字符它可能根本不触发而是静默丢弃后续所有事件。我曾为一个 IoT 设备管理平台开发 SSE 接口设备上报的数据包含传感器原始字节流。某天大量设备上报了乱码数据前端onerror完全没响但控制台疯狂打印Failed to execute postMessage on Window: InvalidStateError。根源是 EventSource 内部解析器遇到非法字符时直接抛出未捕获异常而onerror并不捕获这类解析错误。最终方案是在服务端增加 UTF-8 校验中间件对所有上报数据做iconv-lite编码转换确保输出到 EventSource 的数据 100% 合法。要真正掌控状态必须构建自己的状态机。我的实践方案是初始化状态{ state: INIT, lastOpenTime: 0, errorCount: 0, lastErrorTime: 0 }onopen 更新state OPEN,lastOpenTime Date.now(),errorCount 0onerror 更新state ERROR,errorCount,lastErrorTime Date.now()message 更新state ACTIVE,lastMessageTime Date.now()定时巡检每 5 秒检查state OPEN Date.now() - lastMessageTime 30000则视为“假活”强制重连。这套状态机不依赖readyState也不迷信onerror而是用可测量的业务指标消息到达时间定义健康。4. 从“被动等待”到“主动掌控”生产环境重连策略实战在真实业务场景中放任 EventSource 自动重连无异于裸奔。我们必须基于其底层规则构建一套分层、可控、可观测的主动重连策略。这套策略不是替代 EventSource而是包裹它、增强它、监控它。4.1 分层重连网络层兜底 HTTP 层接管 业务层熔断我将重连策略分为三层每层解决不同维度的问题网络层兜底信任 EventSource 对网络失败的自动重连能力但为其设置最大重试次数和总耗时上限。例如连续 5 次onerror且间隔均小于 3 秒判定为网络不可达触发降级方案如切换备用域名、启用 WebSocket 备用链路。HTTP 层接管当onerror触发且我们怀疑是 HTTP 错误时如已知后端有429限流立即终止当前 EventSource 实例创建新实例并动态调整初始重连间隔。例如首次429后新实例的src改为/stream?retry10000服务端据此返回retry: 10000实现指数退避。业务层熔断基于业务 SLA 设置熔断阈值。例如金融行情要求数据延迟 500ms若连续 10 次message事件的Date.now() - lastMessageTime 1000则判定为服务端性能劣化触发告警并降级到轮询模式。具体代码实现TypeScriptclass SmartEventSource { private es: EventSource | null null; private url: string; private retryCount 0; private maxRetry 5; private baseRetryMs 1000; private lastMessageTime 0; constructor(url: string) { this.url url; } connect() { // 清理旧实例 this.disconnect(); // 构建带重试参数的 URL const retryParam this.retryCount 0 ? ?retry${Math.min(30000, this.baseRetryMs * Math.pow(2, this.retryCount))} : ; this.es new EventSource(this.url retryParam); this.es.onopen () { console.log(SSE connected); this.retryCount 0; this.lastMessageTime Date.now(); }; this.es.onerror (e) { console.error(SSE error:, e); this.retryCount; // 网络层兜底快速失败 if (this.retryCount this.maxRetry) { this.handleMaxRetryExceeded(); return; } // HTTP 层接管指数退避 setTimeout(() { this.connect(); }, Math.min(30000, this.baseRetryMs * Math.pow(2, this.retryCount))); }; this.es.addEventListener(message, (e) { this.lastMessageTime Date.now(); // 处理业务消息 this.handleMessage(e.data); }); } private handleMaxRetryExceeded() { // 触发业务层熔断 console.warn(SSE max retry exceeded, switching to fallback); this.triggerFallback(); } private triggerFallback() { // 例如启动 fetch 轮询或显示离线提示 this.startPolling(); } disconnect() { if (this.es) { this.es.close(); this.es null; } } }4.2 可观测性建设让每一次重连都可追溯、可分析没有监控的重连策略是盲人骑瞎马。我们必须在重连链路的关键节点埋点形成完整的可观测性闭环连接建立阶段记录connect_start_time、connect_end_time、http_status需服务端透传、network_type4G/WiFi/Unknown重连触发阶段记录retry_reasonnetwork/http_429/http_503/parse_error、retry_count、retry_delay_ms数据接收阶段记录message_latency_ms从服务端生成时间戳到前端接收时间差、message_loss_rate基于序列号计算丢包率。我们使用 OpenTelemetry 将这些指标上报到 Prometheus并在 Grafana 中构建了专属看板。其中最关键的两个面板是重连热力图Y 轴为retry_reasonX 轴为小时颜色深浅表示重连次数。我们曾通过此图发现凌晨 2 点http_429重连峰值定位到是定时任务集中调用导致后端限流器误判连接健康度曲线1 - (error_count / total_connection_attempts)阈值设为 99.5%。当曲线跌破阈值自动触发 PagerDuty 告警。注意所有埋点必须异步非阻塞。我曾在一个高并发场景中将console.log埋点放在onerror回调里结果因日志 IO 阻塞导致重连逻辑延迟进一步加剧了连接雪崩。正确做法是使用requestIdleCallback或setTimeout(..., 0)将埋点任务放入微任务队列。4.3 Codex 类场景的专项应对为什么“重连五次”成了玄学解法热搜词中反复出现的codex重连五次解决、codex exceeded retry limit揭示了一个特定场景AI 代码补全服务如 GitHub Copilot 的底层引擎重度依赖 SSE 推送补全建议。这类服务的特点是请求频次极高用户每敲一个字符都可能触发服务端限流严格防止滥用算力客户端 SDK 封装了 EventSource但未暴露底层重连控制权。当用户看到exceeded retry limit, last status: 429 too many requests本质是客户端 SDK 内置的 EventSource 实例在短时间内遭遇了 5 次429响应触发了浏览器网络栈的熔断。此时“重连五次”之所以有效是因为第一次重连仍用原 Token大概率再次429第二次重连SDK 可能刷新了临时 Token权限提升第三次重连用户暂停输入服务端限流窗口重置第四次重连网络路径切换如从 WiFi 切到 4G第五次重连综合以上因素终于成功。但这完全是概率游戏不可靠。我们的应对方案是在 SDK 初始化时注入自定义的retryStrategy// 伪代码劫持 Codex SDK 的 EventSource 创建逻辑 const originalCreateES CodexSDK.createEventSource; CodexSDK.createEventSource function(url, options) { // 动态注入重试参数 const enhancedUrl ${url}?client_id${getClientId()}ts${Date.now()}; const es originalCreateES(enhancedUrl, options); // 监听错误实施主动退避 es.onerror function(e) { if (isRateLimitError(e)) { // 主动延长下次重连间隔避免触发浏览器熔断 setTimeout(() { es.close(); CodexSDK.reconnect(); // 调用 SDK 内置重连 }, 10000); // 强制 10 秒后重试 } }; return es; };通过这种方式我们将“被动等待浏览器重试”升级为“主动控制重试节奏”从根本上规避了exceeded retry limit的发生。5. 绕不开的硬骨头当 EventSource 真的不够用时我们该怎么办EventSource 是优雅的但优雅不等于万能。在某些严苛场景下它的设计哲学服务端主导、单向流、无连接状态会成为性能瓶颈或功能枷锁。这时我们必须清醒地承认是时候换工具了。这不是技术背叛而是工程理性。5.1 场景一需要双向通信或低延迟指令下发EventSource 是单向的服务端→客户端且基于 HTTP/1.1 的长连接天然存在队头阻塞。当业务需要客户端向服务端发送确认、心跳、或实时指令如“暂停推送”、“切换数据源”时EventSource 就捉襟见肘了。此时WebSocket 是更自然的选择。我曾重构一个远程协作白板应用。原方案用 EventSource 推送画布变更用fetch发送用户操作。结果发现当网络拥塞时fetch请求排队导致服务端收到“撤销操作”指令时画布早已被后续的“绘制操作”覆盖产生状态不一致。切换为 WebSocket 后所有消息推送指令走同一条连接服务端可基于消息序列号做严格有序处理端到端延迟从平均 800ms 降至 120ms。关键差异对比特性EventSourceWebSocket通信方向单向Server→Client双向Server↔Client协议基础HTTP/1.1 长连接独立 TCP 连接消息开销每条消息含data:、event:等文本头二进制帧头部仅 2-14 字节连接状态readyState仅反映连接通道ws.readyState可精确到CONNECTING/OPEN/CLOSING/CLOSED错误定位onerror无详情ws.onerror事件对象含error.message5.2 场景二需要细粒度连接控制和多路复用EventSource 的每个实例独占一个 HTTP 连接且无法共享连接池。当页面需要同时监听多个数据源如用户消息、系统通知、实时行情时会建立多个 TCP 连接消耗大量服务端资源和客户端端口。而现代浏览器对同一域名的并发连接数有限制HTTP/1.1 通常为 6 个极易触发连接排队。我们的解决方案是统一网关 多路复用。后端提供一个聚合 SSE 接口/gateway/stream前端只创建一个 EventSource 实例。服务端网关负责订阅所有下游数据源Kafka、Redis Pub/Sub、数据库 CDC将不同来源的消息打上event类型标签event: user_message、event: system_alert按统一格式序列化后推送给前端。前端则根据event字段分发到不同业务模块es.addEventListener(user_message, (e) { handleMessage(JSON.parse(e.data)); }); es.addEventListener(system_alert, (e) { showAlert(JSON.parse(e.data)); });这避免了连接爆炸也降低了服务端维护成本。但代价是增加了网关的复杂度和单点故障风险因此我们为网关部署了双活集群和自动故障转移。5.3 场景三需要兼容老旧环境或极端弱网EventSource 在 IE 中完全不可用在 Android 4.3 及以下版本支持不完整。即使在现代浏览器中其重连机制在极端弱网如 2G、高丢包率下也表现不佳——TCP 重传超时长达数分钟而用户早已离开页面。我们的兜底方案是渐进增强 智能降级。前端启动时先检测window.EventSource是否可用if (typeof EventSource ! undefined) { // 使用 EventSource useEventSource(); } else { // 降级为 Long Polling useLongPolling(); }Long Polling 的实现要点是客户端fetch一个带超时如 30 秒的请求服务端挂起请求直到有新数据或超时客户端收到响应后立即发起下一次请求为防请求堆积客户端维护一个pendingRequestCount超过阈值则暂停新请求。虽然 Long Polling 带来更高服务端压力和延迟但它在任何网络环境下都稳定可靠是 EventSource 最务实的备胎。最后分享一个血泪教训在一次大促保障中我们过度依赖 EventSource 的自动重连未做降级预案。凌晨流量高峰时CDN 节点突发故障EventSource连续onerror而降级逻辑因retryCount判断失误未能触发导致 15 分钟内所有实时数据中断。复盘后我们强制规定任何基于 EventSource 的关键链路必须在 3 秒内完成降级决策且降级路径需经过全链路压测。技术选型没有银弹只有敬畏和准备。