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: 10000),EventSource 会老实照做;但如果你漏发了一次retry,它就会回退到默认的 3 秒。这种“契约式依赖”让调试变得极其反直觉——问题往往不出在客户端代码,而出在服务端响应头或事件格式的毫厘之差。
提示:
readyState是唯一能被 JS 主动读取的状态标识,但它只有三个值:0(CLOSED)、1(OPENING)、2(OPEN)。它永远不会显示“正在重连中”或“重试第2次”。你看到readyState === 0,只代表连接已关闭,至于是主动关闭、网络中断还是服务端返回了非 200 响应,EventSource 不会告诉你。所有关于“重连进度”的判断,必须通过onerror触发频率、时间戳、以及服务端是否返回了retry字段来交叉验证。
2. 重连触发的三重门:什么情况真重连?什么情况直接放弃?
EventSource 的重连决策不是单一条件判断,而是由网络层状态、HTTP 响应码、服务端事件指令三重门共同把关。绝大多数人只盯着onerror回调,却忽略了前两道门早已决定了第三道门是否开启。我们逐层拆解这三重门的通行规则:
2.1 第一重门:网络层失败 —— 唯一 guaranteed 重连的场景
这是 EventSource 规范中唯一明确保证会触发重连的情况。当底层 TCP 连接因以下原因中断时,浏览器会立即启动重连流程:
- DNS 查询失败(
net::ERR_NAME_NOT_RESOLVED) - TCP 连接超时(
net::ERR_CONNECTION_TIMED_OUT) - TLS 握手失败(
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 f"data: message-{i}\n\n" time.sleep(1) return Response(event_stream(), mimetype='text/event-stream')前端监听到event: error后,知道服务端主动告知了异常,结合retry: 5000,就能准确预判下一次连接将在 5 秒后发起。这种“服务端主导、客户端协同”的模式,比纯前端轮询或指数退避更精准、更省资源。
3. readyState 的幻觉与 onerror 的沉默:如何真正掌握连接状态?
readyState和onerror是开发者感知 EventSource 状态的唯二公开接口,但它们提供的信息远比表面看起来更模糊、更具误导性。很多线上故障的根因,正是源于对这两个 API 的过度信任。
3.1 readyState:一个只有“开关”没有“刻度”的仪表盘
readyState的三个值(0/1/2)看似简单,实则隐藏着巨大的状态盲区:
0(CLOSED):连接已关闭。但关闭原因未知——可能是你调用了eventSource.close(),也可能是网络中断,也可能是服务端返回了429,还可能是浏览器内存不足主动回收。你无法区分。1(OPENING):连接正在建立中。但这个状态可能持续任意长时间——DNS 查询、TCP 握手、TLS 协商、服务端处理,任何一个环节卡住,readyState都会卡在1。而onerror只有在超时或失败后才触发,期间你完全无法得知卡在哪一步。2(OPEN):连接已建立,数据正在流式接收。但这不保证数据一定在流动——服务端可能因 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 = 0 - onerror 更新:
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?retry=10000,服务端据此返回retry: 10000,实现指数退避。 - 业务层熔断:基于业务 SLA 设置熔断阈值。例如,金融行情要求数据延迟 < 500ms,若连续 10 次
message事件的Date.now() - lastMessageTime > 1000,则判定为服务端性能劣化,触发告警并降级到轮询模式。
具体代码实现(TypeScript):
class 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_type(4G/WiFi/Unknown); - 重连触发阶段:记录
retry_reason(network/http_429/http_503/parse_error)、retry_count、retry_delay_ms; - 数据接收阶段:记录
message_latency_ms(从服务端生成时间戳到前端接收时间差)、message_loss_rate(基于序列号计算丢包率)。
我们使用 OpenTelemetry 将这些指标上报到 Prometheus,并在 Grafana 中构建了专属看板。其中最关键的两个面板是:
- 重连热力图:Y 轴为
retry_reason,X 轴为小时,颜色深浅表示重连次数。我们曾通过此图发现凌晨 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。
关键差异对比:
特性 EventSource WebSocket 通信方向 单向(Server→Client) 双向(Server↔Client) 协议基础 HTTP/1.1 长连接 独立 TCP 连接 消息开销 每条消息含 data:、event:等文本头二进制帧,头部仅 2-14 字节 连接状态 readyState仅反映连接通道ws.readyState可精确到CONNECTING/OPEN/CLOSING/CLOSED错误定位 onerror无详情ws.onerror事件对象含error.message
5.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 秒内完成降级决策,且降级路径需经过全链路压测。技术选型没有银弹,只有敬畏和准备。