HarmonyOS 网络请求超时与流式下载:readTimeout 到底管多久

前言

@ohos.net.http 大概是 ArkTS 生态里被写得最多、也错得最多的一个模块。它表面上只有「建实例、填 options、发请求、destroy」四步,门槛低到几乎是每个新手第一个跑通的网络调用;但真正决定线上成败的几个语义,恰恰藏在文档最容易一扫而过的那几行里。

最典型的是超时。绝大多数人对 connectTimeoutreadTimeout 的理解停留在字面:一个是「连的时候超时」,一个是「读的时候超时」。这个理解听起来天经地义,写出来的代码也不会报错、不会崩,甚至单元测试都能过——直到某天你调大 connectTimeout 去抢救一个慢接口,发现它比以前更早失败;或者某个几百兆的资源包下载,连接明明成功了,进度条却总在固定时刻断掉。这类问题的排查成本极高,因为错误信息只有一句 Request timeout,谁看都像是服务端慢。

除此之外还有一串「默认值陷阱」和「使用姿势陷阱」:缓存默认是开的、GETextraData 传参不会生效、流式下载的分片累积写法不对会把内存和 CPU 一起打满、事件监听不注销会泄漏。它们零星地散在文档各处,单看每一条都不难,凑在一起就变成了「网络模块玄学」。

这篇文章从论坛上一个具体的提问切入,先把三处流传很广的 API 级错误纠正干净,再重点讲透 readTimeout 的真实语义,然后沿「请求方式 → 配置 → 流式下载 → 取消与释放 → 错误码 → 配置文件」这条链路,给出一套可以直接落在生产代码里的完整封装。

问题描述

论坛原帖的问题本身很朴素,楼主问了两个问题:

  1. HarmonyOS 的 @ohos.net.http 模块支持哪几种请求方式?
  2. 如何设置请求超时时间?

这是标准的入门问题,官方文档里就有答案:支持 GETPOSTOPTIONSHEADPUTDELETETRACECONNECT 八种方法,小数据量用 HttpRequest.request,大文件上传下载且关心进度用 HttpRequest.requestInStream,超时在 HttpRequestOptions 里通过 readTimeoutconnectTimeout 配置,默认都是 60000ms。

问题出在回帖上。几个回答里混进了三处硬伤,而且它们的危害程度完全不同

  • 第一处是编译期错误。有回答给出了 expectTimeout: 10000,并声称这是「HttpRequestOptions 的属性,默认值 5000ms」。ArkTS 的对象字面量只允许已知属性,写这个字段会直接编译报错;而且默认值也不是 5 秒,readTimeoutconnectTimeout 的官方默认值都是 60000ms。这处错误好在「错得很响」,编译不过就改掉了。
  • 第二处是理解偏差。有回答把 RequestMethod 描述成 OPTIONS(0)GET(1)HEAD(2)POST(3)……一直排到 CONNECT(7),即「枚举序号」。实际它是字符串枚举,枚举值就是 'OPTIONS''GET' 这样的大写方法名字符串。这处错误有一个潜伏期:如果你拿数字去比较、落库或者做协议映射,代码不会报错,只会在某个判断分支上悄悄走错。
  • 第三处是语义误解,也是本文的重点。多个回答都把 readTimeout 描述为「连接成功后读取响应数据的超时」,把 connectTimeout 描述为「TCP 建立连接的超时」,于是很自然地给出「连接阶段 8 秒、读取阶段 15 秒」这类配置。这正是导致前述两类「玄学故障」的认知根源。

所以真正需要回答的不是「有几种方法、怎么设超时」这两个原地问题,而是三个更深的问题:

  1. readTimeout 计时的起止点到底在哪里?它管的是哪一段?
  2. 基于这个语义,connectTimeoutreadTimeout 之间必须满足什么关系?大文件下载该怎么配?
  3. 流式下载、取消、重试、资源释放这几个动作,各自有哪些「顺序错了就出问题」的细节?

细节解析

1. 先纠正三处 API 级错误

逐条说清,因为后面所有结论都建立在这三条之上。

(1)expectTimeout 不是 HttpRequestOptions 的字段。

HttpRequestOptions 里与超时相关的字段只有两个connectTimeoutreadTimeout,两者的默认值都是 60000ms(60 秒),单位毫秒,传入值需要是 uint32_t 范围内的整数。

expectTimeout 这个词在不同技术栈里确实存在过——比如某些网络库的「期望超时」概念——但它不属于 HarmonyOS 的 http 模块。ArkTS 是强类型 + 对象字面量属性封闭的,写一个不存在的属性会在编译期就被拦下,报的是「对象字面量只能指定已知属性」。好消息是这类错误不会流到运行时;坏消息是,如果 AI 生成的示例代码里混了这个字段,而你又习惯性地「先跑通再说」,就会在第一步卡住,甚至误以为是自己 SDK 版本不对。

顺便把默认值这件事钉死:两个超时的默认值都是 60000ms,没有 5000ms 这回事。这一点很重要,因为默认值决定了「我以为我没配超时,其实我有 60 秒上界」——在长耗时任务里,这个上界会真的触发。

(2)RequestMethod 是字符串枚举,不存在 0~7 的序号映射。

http.RequestMethod 的枚举值就是方法名字符串本身:

// 实际类型等价于:'OPTIONS' | 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'TRACE' | 'CONNECT' | 'PATCH'

也就是说 http.RequestMethod.GET 在运行时就是字符串 'GET',而不是数字 1。这带来三个必须注意的后果:

  • 不要用数字比较。如果你从网络、数据库、配置中心拿到一个数字再映射成 RequestMethod,映射表必须是你自己维护的,不能依赖「声明顺序即序号」——声明顺序是实现的细节,不是契约。
  • 不要直接落库或序列化后做协议约定。既然它是字符串,落库就落字符串,别把它当数字存进 int 列,否则一旦顺序调整,历史数据全部错位。
  • 需要反向判断时用字符串比较。比如判断是否需要请求体,写 method === http.RequestMethod.GET || method === http.RequestMethod.HEAD 是可读且安全的;写 methodNum < 3 这种就是在赌实现细节。

另外,method 是可选项,不填默认是 GET。这一点在封装里要显式处理,不要把默认行为留给调用方去猜。

(3)readTimeout 不是「读数据阶段的超时」。

这是最关键的一条,也是本文的核心论点,下一节展开。

2. readTimeout 的真实语义:它是「总时长上界」

官方对 readTimeout 的定义是:从请求开始到请求结束的总时间,包括 DNS 解析、连接建立、传输等全部阶段

请务必把这句定义逐字读完。它不是「读取响应的超时」,而是整个请求生命周期的总预算(total time budget)。这个语义与大多数人对 HTTP 库的直觉完全不同——在 OkHttp、axios 这类主流实现里,readTimeout 通常只覆盖「两次数据包之间的间隔」或「从连接建立到响应完成」,而连接阶段由 connectTimeout 单独计时、互不重叠。HarmonyOS 这里不是这样:readTimeout 是一个从「调用开始」就开始跑的墙钟计时器。

理解了这一点,很多「玄学」立刻变成必然。

推论一:把 connectTimeout 配得比 readTimeout 大,是自相矛盾的配置。

// 看起来是「连接给 30 秒、读取给 10 秒」,实际逻辑上不可能成立
const options: http.HttpRequestOptions = {
  connectTimeout: 30000,
  readTimeout: 10000
};

因为总时长上界只有 10 秒,连接阶段根本不可能跑满 30 秒——它在第 10 秒就会被 readTimeout 打断。你以为给了连接 30 秒的耐心,实际只给了 10 秒;而更糟的是,这种情况下报出来的错误是超时,你会误以为是服务端响应慢,跑去优化服务端接口,方向完全偏了。

这里有一个必须强调的结论:connectTimeout 的唯一意义,是在总预算之内为「建连」单独划一条更早的警戒线。它的合理取值范围是「明显小于 readTimeout」,而不是「大于」。典型配置是让 connectTimeoutreadTimeout 的 30%~50%:总预算 20 秒时建连给 5~8 秒,这样建连慢到离谱时能提前失败换取更快的重试,而正常请求又能用满剩余预算。

推论二:要给长耗时请求留时间,要放大的是 readTimeout

这是大文件下载场景最常见的错误配置。很多人遇到下载失败,第一反应是把 connectTimeout 调大:

// 错误示范:连上了,但下载必然超时
const options: http.HttpRequestOptions = {
  connectTimeout: 120000,   // 以为调大这个就能下载更久
  readTimeout: 15000        // 总预算仍然只有 15 秒
};

结果就是「连接秒开,进度条走到 15 秒必断」。因为决定下载能跑多久的是 readTimeoutconnectTimeout 只影响建连阶段。大文件下载必须放大 readTimeout,或者干脆设为 0。

推论三:readTimeout: 0 表示不超时,但不要随手写 0。

文档明确写了「设置为 0 表示不会出现超时情况」。对于「总大小不确定、网速不可控」的流式下载,这确实是合理选择——但它的代价是请求可能永久挂住。网络假死(TCP 连接还在、对端不响应)时不会有任何一方主动断开,请求会一直占着一个 HttpRequest 实例和对应的 socket。

所以正确姿势是:要么给一个足够大的有限值(比如 10 分钟),要么设 0 但在业务层用计时器兜底。后面示例代码里的 totalBudget 参数就是干这个的,用 Promise.race 之外的方式再包一层墙钟计时器,到达预算就主动 destroy() 中断。这是「不超时」和「安全」之间唯一能同时成立的做法。

推论四:readTimeout 是硬上界,不是「空闲超时」。

这一点对 SSE、长轮询这类「连接一直开着、数据慢慢吐」的场景影响极大。假如服务端每隔 30 秒推一次心跳,总时长会持续拉长,如果 readTimeout 设成了 60 秒——注意这里的 60 秒是从请求开始算起的总时长,不是「两次数据之间的间隔」——那么第 61 秒请求就会被强制中断,无论服务端是不是还在正常推数据。

对这类长连接场景,只有两个选择:把 readTimeout 设成 0(然后在业务层自己做心跳检测和重连),或者改用别的长连接通道。不要试图用「把 readTimeout 调到比心跳间隔大一点」来解决,因为总时长是无界的,调多大都会到。

把上面几条整理成一张配置矩阵,落地时可以直接对照:

场景connectTimeoutreadTimeout要点
普通 JSON 接口3000~800015000~20000connect 控制在 read 的 30%~50%
首屏关键接口(要快失败)2000~30005000~8000短超时 + 业务层降级兜底
弱网/移动网络下的写操作800030000~60000写操作要留足预算,配合幂等重试
大文件下载(进度可感知)100000 或 600000放大 readTimeout,用业务层计时器兜底
大文件上传100000 或 600000同上,且要处理分片重传
SSE / 长轮询80000心跳与重连自己做,别依赖 readTimeout

3. 请求方式与接口选择

http.RequestMethod 提供八种方法:GETPOSTPUTDELETEHEADOPTIONSTRACECONNECT,默认 GET。从 API 26 开始新增 PATCH——注意 PATCH 在 REST 语义里非常常用(局部更新),如果你的 SDK 版本低于 26 而接口又只提供 PATCH,只能降级用 PUT 全量提交,或者请服务端补一个 POST 别名端点。

需要注意,这八种方法是 HTTP 协议层的全集,不代表服务端都支持。实际项目里真正用到的通常就是 GETPOSTPUTDELETE 四种,HEAD 适合做「只探测资源是否存在/大小」的轻量检查(响应无 body,带宽开销极小),OPTIONS 一般用于 CORS 预检或探测服务端能力。

接口层面的选择很清晰:

  • 数据量小、要一次性拿到完整响应体HttpRequest.request。返回值是 HttpResponseresult 里是完整响应体,responseCode 是状态码。绝大多数业务接口都属于这一类。
  • 大文件上传/下载,且关心进度HttpRequest.requestInStream。这里有一个极其容易被忽略的差异requestInStream 的 Promise 只 resolve 出响应码,没有 HttpResponse响应体只能通过 on('dataReceive') 事件分片接收。很多人在 await 完之后去找 result,找不到就以为 API 有问题。

另外从 API 22 起提供了 HTTP 拦截器(Interceptor),可以在「请求-响应」生命周期的关键节点插入统一逻辑。它对中大型项目价值很大:签名计算、公共 header 注入、埋点统计、统一重试策略,都可以收敛到拦截器里各写一次,而不是在每个调用点复制粘贴。如果你还在用「每个接口函数里手动加 token」的方式,值得排期迁移。

4. 三个默认值陷阱

(1)usingCache 默认是 true

这是最容易造成「查半天的怪问题」的一条。GET 请求默认走缓存,意味着服务端数据已经变了、客户端拿到的还是旧值。表现是「下拉刷新没反应」「杀掉重进才好」「换个账号还是看到上个账号的数据」。

必须显式关闭缓存的场景:订单状态、余额、消息未读数、登录态相关、任何带时效性的查询。可以在封装层把 usingCache 的默认值直接反转成 false——HTTP 缓存对业务数据带来的收益有限,带来的困惑却是无限的;只有明确的静态资源(图标、配置包、字典)才值得开缓存。这是我在示例代码里默认 false 的原因。

(2)GET 不要用 extraData 传参数。

extraData 的语义是请求体GETHEAD 在协议上没有请求体,所以 extraData 不会被自动拼进 URL——表现就是「参数没传过去,服务端收到的是空查询」。

正确做法有两种:自己用 encodeURIComponent 拼接 URL 参数,或者从 API 26 起使用新增的 queryParams 字段(可传 string 或对象,编码交给框架处理,更省心)。如果你的最低支持版本低于 26,就必须保留自己的拼接逻辑,而且每个 key 和 value 都要编码——中文、空格、&=+ 这些字符不编码会直接破坏查询串结构。

(3)result 的类型取决于 expectDataType

不设置时默认是 STRING,此时响应体是字符串,服务端的 JSON 要自己 JSON.parse。这里有个很实际的坑:JSON.parse 必须包 try/catch。因为服务端返回错误时很可能吐一坨 HTML(网关错误页、502 页面、登录跳转页),JSON.parse 会直接抛异常,而这个异常会掩盖真正的 HTTP 状态码,让你以为「解析失败」而不是「服务端 502」。

另一个选择是设成 http.HttpDataType.OBJECT,让框架按 Content-Type 自动解析成对象,省掉一次手工解析。但它同样解决不了「服务端返回 HTML」的问题。最稳的写法是:拿到响应先判 responseCode,非 2xx 时把 result纯文本截断记日志(不要解析),只有状态码正常时才尝试解析,并用 resp.resultType 做二次判断,不做硬类型转换。

5. 流式下载:为什么分片累积会退化成 O(n²)

requestInStream 的响应体靠 on('dataReceive') 分片推送,每片是一个 ArrayBuffer。问题在于「怎么把分片攒起来」。

第一种错误写法是 res = res + chunk。这个连类型都过不去:ArrayBuffer不可变的定长缓冲区,没有 + 运算,也不能就地追加。所以这条路在第一行就被编译器堵死了。

第二种错误写法更隐蔽、也更常见:在回调里每收到一片,就「分配一个更大的新数组、把已收到的全部拷贝进去、再放回变量」。

// 反面示例:每片都全量重分配 + 全量拷贝
let buffer = new Uint8Array(0);
client.on('dataReceive', (piece: ArrayBuffer) => {
  const view = new Uint8Array(piece);
  const next = new Uint8Array(buffer.byteLength + view.byteLength);
  next.set(buffer, 0);        // 把历史数据全部再搬一遍
  next.set(view, buffer.byteLength);
  buffer = next;              // 每来一片,前面的数据被重复搬运一次
});

这是妥妥的 O(n²)。假设下载 200MB、分片大小 64KB,总共约 3200 片;第 k 片要做一次 k × 64KB 的拷贝,累计拷贝量约为 3200 × 3201 / 2 × 64KB ≈ 320GB。结果就是内存峰值狂涨、CPU 被 memcpy 打满、下载越来越慢,最后可能因为内存不足直接崩掉。而且这种崩溃现场看起来像是「内存泄漏」,实际是算法复杂度问题。

正确做法只有两条原则:

  • 回调里只做 O(1) 的事:把分片引用 push 进数组、累加总长度。不要拷贝,不要拼接。
  • dataEnd 里一次性合并:此时已知总长度,按顺序 set 进一个预分配好的 Uint8Array,整体只有一次 O(n) 的搬运。

这样每个分片只被拷贝一次,总拷贝量等于文件大小,是理论最优。

这里有一个常被追问的细节:把回调里的 ArrayBuffer 存起来安全吗? 存引用是官方示例的通行写法,事件回调传入的缓冲区不会被复用(如果会被复用,官方示例本身就是错的)。但如果你所在的项目对这一点有顾虑,或者需要跨线程/跨任务传递,那么每个分片拷贝一次是完全可以接受的替代方案——它对每个字节只做一次拷贝,整体仍然是 O(n),只是常数项比零拷贝方案大一倍,绝对不会退化成 O(n²)。判断标准只有一个:不要在任何时候重新搬运「历史已累计的数据」

下载进度用 on('dataReceiveProgress') 获取,回调入参是 DataReceiveProgressInfo,包含 receiveSizetotalSize。这里要注意:totalSize 依赖服务端返回 Content-Length,如果服务端用了 chunked 编码、或者做了 gzip 压缩,totalSize 可能是 0 或与实际下载字节数不一致。所以进度条要做兼容——totalSize > 0 时显示百分比,否则只显示「已下载 X MB」的滚动文案,不要拿 0 去做除数。

6. 释放资源的正确顺序:先 off 再 destroy

这一条看起来只是代码风格,实际是「错顺序就残留监听」的硬性约束。

正确的顺序是:先注销所有 on 注册的监听,再调用 destroy()

// 正确顺序
client.off('dataReceive');
client.off('dataReceiveProgress');
client.off('dataEnd');
client.destroy();

原因在于这两步做的事情不同:off() 是把你注册的回调从事件表里摘掉,destroy() 是释放实例持有的底层资源(socket、缓冲区、事件目标)。如果先 destroy()off(),实例内部状态已经被拆掉了,此时再去操作事件表,行为是未定义的——轻则注销无效、回调残留在闭包链上阻止相关对象被回收,重则在某些版本上直接抛异常。而且这类问题在开发期几乎不显现,因为单次请求的泄漏量很小;只有高频请求(列表页连续滚动加载、轮询)跑久了才会表现为内存持续上涨。

同时要记住:只要用了 on,就必须有成对的 off。这包括不太引人注意的 on('headersReceive')——它会在 request 的回调之前先返回响应头,适合提前读取 Content-LengthContent-Type、自定义业务头,但同样需要 off('headersReceive')。最稳的结构是把 on 的注册放在 try 之前(或 try 开头)、把全部 off + destroy 放在 finally 里,这样无论成功、失败还是异常,资源都一定被回收。

关于取消,有一个必须知道的事实:http 模块没有独立的 cancel 接口。要中断一个进行中的请求,唯一手段就是 destroy(),代价是它以错误回调结束——你会收到一个错误,而不是一个「已取消」的正常返回。这带来两个实践要求:

  1. 业务层要能区分「超时失败」和「主动取消」。否则用户划走页面导致的取消会触发一次无意义的重试,甚至弹出一个错误提示。做法是在封装里维护一个取消标记,取消发生后识别到标记就直接结束、不重试、不报错。
  2. 不要随意 destroy() 一个还需要的结果的请求。典型误用是在页面的 aboutToDisappear 里无条件销毁所有在途请求;如果这个请求的目的地是全局缓存或下一屏要用的数据,你就会白跑一次。取消的粒度应该是「实例级」的,由调用方持有并决定何时取消。

7. 超时兜底、错误码与重试分层

超时配置解决的是「框架认识到的超时」,但还有一层是「框架没认识到、业务认为已经超时」。典型场景:readTimeout 设成了 0,或者 DNS 阶段卡在系统解析器里连计时都没开始。所以业务层需要自己的兜底计时器——这也是我前面反复强调的「readTimeout: 0 必须配兜底」的原因。

兜底计时器要注意两点:一是它应该是墙钟时间(从发起请求开始算),而不是事件间隔;二是它触发时必须同时做两件事——destroy() 中断底层请求、用一个明确的业务错误 reject 掉外层 Promise。只 reject 不 destroy 的话,底层请求还在跑,连接和实例都没释放,属于「假取消」。

错误码层面,重试策略必须分层,不能一视同仁。常见的 http 错误码及处置:

错误码含义是否值得重试
2300003URL 格式错误否,参数问题
2300006无法解析主机(DNS)是,可能是临时 DNS 故障
2300007无法连接到服务器是,典型的网络抖动
2300008服务端返回的响应无法解析视情况,可能是网关问题
2300009访问被拒绝否,权限/鉴权问题
2300016无法获取网络连接是,但应等网络恢复再试
2300028请求超时是,最适合退避重试
2300060服务器证书校验失败否,安全配置问题

判断原则可以浓缩成一句话:参数类、鉴权类、证书类的错误重试没有意义,重试只会浪费时间并把错误放大;网络类、超时类、服务端 5xx/408/429 才值得退避重试。

退避策略不要用固定间隔。固定 100ms 重试三次,在服务端过载时等于给它补刀。推荐指数退避(base * 2^attempt),并且加一点随机抖动(jitter),避免大量客户端在同一时刻集体重试形成新的尖峰。重试次数也不宜多,2~3 次足够覆盖绝大多数瞬时抖动;对于写操作POST 创建订单这类),重试必须配合幂等键,否则一次网络超时 + 一次重试就可能创建两条订单。

还有一个容易忽略的取舍:重试的总时间不能超过用户的耐心。如果单次预算 20 秒、重试 3 次,最坏情况用户要等 60 多秒。所以重试应该和「总预算」联动——示例代码里把 totalBudget 做成可继承的剩余时间,超出预算就不再重试。

8. network_config.json 与代码内配置的优先级

最后讲一个「配置找不到」的重灾区。

明文 HTTP 的配置文件位于 src/main/resources/base/profile/network_config.json,关键字段是 cleartextTrafficPermitted,用来控制是否允许明文流量。API 18 起 Network Kit 默认允许明文,所以「我什么都没配,HTTP 也能跑」是默认行为,不是你的配置生效了。

配置文件内部是三层结构,优先级为 component-config > domain-config > base-config。层级越具体,优先级越高:

  • base-config 是全局默认;
  • domain-config 对指定域名单独放行或禁止;
  • component-config 针对具体组件(比如 Network Kit)单独设置。

最常见的故障形态是:为了实现「生产环境默认禁明文」的安全要求,在 base-config 里把 cleartextTrafficPermitted 关掉,结果HTTPS 请求全部正常、HTTP 请求全部失败,而且报错信息完全看不出是配置问题。原因就是只关了全局,忘了给必须走 HTTP 的内网域名(或 Network Kit 组件)单独开回来。

这里要说清一个常被误解的点:network_config.json 和代码内的 HttpRequestOptions 不存在「谁覆盖谁」的关系,因为它们管的不是同一层的东西。

  • network_config.json准入层:能不能走明文、走不走证书校验。它决定请求「通不通」。
  • HttpRequestOptions行为层:超时多久、走不走缓存、用什么协议版本、走不走系统代理。它决定请求「怎么走」。

代码里没有任何一个字段可以覆盖 cleartextTrafficPermitted 的决定。你不可能靠设一个 usingProtocol 或某个开关绕过明文准入策略。所以排查时要先分清层次:如果现象是「HTTPS 全通、HTTP 全挂」,那一定是 network_config.json 层的问题,跟超时、缓存、重试统统无关,不要在这些地方浪费时间。

生产环境的建议做法是反过来:base-config 里默认禁止明文,只对「确实必须用 HTTP」的内网域名用 domain-config 精确放开,并在注释里写清为什么这个域名不能上 HTTPS。同时记住:修改 network_config.json 后需要重新编译打包,热重载不一定能生效——改完不生效时,先怀疑缓存,再怀疑配置。

另外补一条与网络强相关但常被搞混的权限问题:ohos.permission.INTERNETsystem_grant 权限,安装即授予,不需要调用 requestPermissionsFromUser 动态申请,只在 module.json5 里声明即可。需要动态申请的是定位、相机、麦克风这一类 user_grant 权限。很多人把「网络请求失败」误判为「权限没申请」,跑去写一段动态申请代码,然后在权限列表里找不到 INTERNET,凭空绕了很大一圈。

示例代码

下面是一份完整的封装,覆盖超时归一化(含 connectTimeout >= readTimeout 的自相矛盾配置修正)、分层重试与指数退避、流式下载与进度回调、主动取消、资源释放顺序,以及业务层总预算兜底。

// NetworkClient.ets
// 基于 @kit.NetworkKit 的 http 封装:超时归一化 / 分层重试 / 流式下载 / 取消 / 资源释放
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';

/** 归一化后的默认值:官方默认 readTimeout / connectTimeout 均为 60000ms */
const DEFAULT_CONNECT_TIMEOUT: number = 8000;
const DEFAULT_READ_TIMEOUT: number = 20000;
/** connectTimeout 占 readTimeout 的目标比例(超出则按此收紧) */
const CONNECT_READ_RATIO: number = 0.5;

/** 可重试的底层错误码:网络抖动 / 超时 / 无网络 */
const RETRYABLE_CODES: number[] = [2300006, 2300007, 2300008, 2300016, 2300028];
/** 可重试的 HTTP 状态码:超时 / 限流 / 服务端错误 */
const RETRYABLE_STATUS: number[] = [408, 425, 429, 500, 502, 503, 504];

export const ERR_CANCELLED: number = -1000;
export const ERR_BUDGET_EXCEEDED: number = -1001;
export const ERR_BAD_RESPONSE: number = -1002;

/** 统一的错误对象,retryable 决定是否进入退避重试 */
export class HttpError {
  code: number = 0;
  message: string = '';
  retryable: boolean = false;
  /** 服务端返回的原始响应体(截断后),便于排查 HTML 错误页 */
  rawSnippet: string = '';

  constructor(code: number, message: string, retryable: boolean, rawSnippet: string = '') {
    this.code = code;
    this.message = message;
    this.retryable = retryable;
    this.rawSnippet = rawSnippet;
  }
}

export interface RequestConfig {
  method?: http.RequestMethod;
  header?: Record<string, string>;
  /** 请求体:string 原样发送,对象自动 JSON 序列化,ArrayBuffer 直传 */
  body?: string | object | ArrayBuffer;
  /** URL 查询参数(API 26+ 可用 queryParams,此处自带编码以保证低版本一致) */
  queryParams?: Record<string, string | number | boolean>;
  connectTimeout?: number;
  readTimeout?: number;
  usingCache?: boolean;
  expectDataType?: http.HttpDataType;
  priority?: number;
  /** 追加重试次数,不含首次请求,默认 0 */
  retryCount?: number;
  /** 退避基数,实际延迟 = base * 2^n,并叠加抖动 */
  retryBaseDelay?: number;
  /** 业务层墙钟总预算,超出即判定失败并中断请求;0 表示不设 */
  totalBudget?: number;
}

export interface HttpResult<T> {
  code: number;
  data: T;
  header: Record<string, string>;
  /** 从发起到结束的真实耗时,用于观测 readTimeout 是否逼近上界 */
  durationMs: number;
}

export interface DownloadProgress {
  receiveSize: number;
  totalSize: number;
  /** totalSize 未知时为 -1,UI 层据此切换为「已下载 X MB」文案 */
  percent: number;
}

/** 取消令牌:http 没有独立 cancel 接口,只能通过 destroy() 中断 */
export class CancelToken {
  private cancelled: boolean = false;
  private client: http.HttpRequest | undefined = undefined;
  private waiters: Array<() => void> = [];

  get isCancelled(): boolean {
    return this.cancelled;
  }

  /** 由 NetworkClient 内部调用,绑定当前在途实例 */
  bind(client: http.HttpRequest): void {
    this.client = client;
    if (this.cancelled) {
      // 先取消后绑定的竞态:立即中断,避免请求逃逸
      client.destroy();
    }
  }

  unbind(): void {
    this.client = undefined;
  }

  /** 等待取消信号,用于流式下载这类需要主动等待的流程 */
  waitCancelled(): Promise<void> {
    if (this.cancelled) {
      return Promise.resolve();
    }
    return new Promise<void>((resolve: () => void) => {
      this.waiters.push(resolve);
    });
  }

  cancel(): void {
    if (this.cancelled) {
      return;
    }
    this.cancelled = true;
    if (this.client !== undefined) {
      this.client.destroy();
    }
    const pending = this.waiters;
    this.waiters = [];
    for (const notify of pending) {
      notify();
    }
  }
}

export class NetworkClient {
  /**
   * 修正自相矛盾的超时配置:
   * readTimeout 是「请求开始到结束」的总时长上界,
   * 因此 connectTimeout 必须严格小于它,否则建连永远跑不满就被打断。
   */
  private static normalizeTimeouts(cfg: RequestConfig): Record<string, number> {
    const connect: number = cfg.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT;
    const read: number = cfg.readTimeout ?? DEFAULT_READ_TIMEOUT;
    const result: Record<string, number> = { 'connect': connect, 'read': read };
    if (read !== 0 && connect >= read) {
      const tightened: number = Math.max(1, Math.floor(read * CONNECT_READ_RATIO));
      console.warn(`[NetworkClient] connectTimeout(${connect}) 已达/超过 readTimeout(${read}),` +
        `建连不可能跑满总预算,自动收紧为 ${tightened}ms`);
      result.connect = tightened;
    }
    return result;
  }

  /** 为 GET/HEAD 之外的方法补齐请求体序列化 */
  private static serializeBody(cfg: RequestConfig): string | ArrayBuffer | undefined {
    const method: http.RequestMethod = cfg.method ?? http.RequestMethod.GET;
    // GET / HEAD 没有请求体,extraData 不会被拼进 URL,必须显式忽略
    if (method === http.RequestMethod.GET || method === http.RequestMethod.HEAD) {
      return undefined;
    }
    if (cfg.body === undefined) {
      return undefined;
    }
    if (cfg.body instanceof ArrayBuffer) {
      return cfg.body;
    }
    if (typeof cfg.body === 'string') {
      return cfg.body;
    }
    return JSON.stringify(cfg.body);
  }

  /** 手工拼接查询串,key/value 都必须编码,避免中文与 & = + 破坏结构 */
  private static appendQuery(url: string, params?: Record<string, string | number | boolean>): string {
    if (params === undefined) {
      return url;
    }
    const keys: string[] = Object.keys(params);
    if (keys.length === 0) {
      return url;
    }
    const parts: string[] = [];
    for (const key of keys) {
      const value: string = encodeURIComponent(String(params[key]));
      parts.push(`${encodeURIComponent(key)}=${value}`);
    }
    const joiner: string = url.includes('?') ? '&' : '?';
    return url + joiner + parts.join('&');
  }

  private static buildOptions(cfg: RequestConfig, stream: boolean): http.HttpRequestOptions {
    const timeouts: Record<string, number> = NetworkClient.normalizeTimeouts(cfg);
    const header: Record<string, string> = cfg.header ?? {};
    if (header['Content-Type'] === undefined && cfg.body !== undefined) {
      header['Content-Type'] = 'application/json';
    }
    const options: http.HttpRequestOptions = {
      method: cfg.method ?? http.RequestMethod.GET,
      header: header,
      expectDataType: cfg.expectDataType ?? http.HttpDataType.STRING,
      // 业务数据默认关缓存:usingCache 官方默认为 true,GET 会命中缓存导致读到旧值
      usingCache: cfg.usingCache ?? false,
      connectTimeout: timeouts.connect,
      readTimeout: timeouts.read,
      priority: cfg.priority ?? 1
    };
    // 流式下载只拿响应码,响应体从 dataReceive 事件取
    const body: string | ArrayBuffer | undefined = stream ? undefined : NetworkClient.serializeBody(cfg);
    if (body !== undefined) {
      // 推荐字段为 body(API 26+);extraData 语义一致且兼容更低版本
      options.extraData = body;
    }
    return options;
  }

  private static isRetryableCode(code: number): boolean {
    if (code === ERR_CANCELLED) {
      return false;
    }
    if (code === ERR_BAD_RESPONSE) {
      return true;
    }
    return RETRYABLE_CODES.includes(code) || RETRYABLE_STATUS.includes(code);
  }

  private static toHttpError(e: Object | undefined): HttpError {
    const err = e as BusinessError;
    const code: number = err?.code ?? -1;
    const message: string = err?.message ?? 'unknown network error';
    return new HttpError(code, message, NetworkClient.isRetryableCode(code));
  }

  private static async sleep(ms: number): Promise<void> {
    return new Promise<void>((resolve: () => void) => {
      setTimeout(() => resolve(), ms);
    });
  }

  /** 业务层墙钟兜底:到点强制 destroy() 中断底层请求,再以错误结束外层 Promise */
  private static withBudget<T>(task: Promise<T>, budgetMs: number, token: CancelToken): Promise<T> {
    if (budgetMs <= 0) {
      return task;
    }
    return new Promise<T>((resolve: (v: T) => void, reject: (e: Object) => void) => {
      let finished: boolean = false;
      const timer: number = setTimeout(() => {
        if (finished) {
          return;
        }
        finished = true;
        token.cancel();
        reject(new HttpError(ERR_BUDGET_EXCEEDED,
          `业务总预算 ${budgetMs}ms 用尽,已中断请求`, false));
      }, budgetMs);
      task.then((value: T) => {
        if (finished) {
          return;
        }
        finished = true;
        clearTimeout(timer);
        resolve(value);
      }).catch((e: Object) => {
        if (finished) {
          return;
        }
        finished = true;
        clearTimeout(timer);
        reject(e);
      });
    });
  }

  /** 单次请求:一次一实例,finally 里 destroy */
  private async once<T>(url: string, cfg: RequestConfig, token: CancelToken): Promise<HttpResult<T>> {
    const client: http.HttpRequest = http.createHttp();
    token.bind(client);
    const start: number = Date.now();
    try {
      const requestUrl: string = NetworkClient.appendQuery(url, cfg.queryParams);
      const options: http.HttpRequestOptions = NetworkClient.buildOptions(cfg, false);
      const task: Promise<http.HttpResponse> = client.request(requestUrl, options);
      const resp: http.HttpResponse = await NetworkClient.withBudget(task, cfg.totalBudget ?? 0, token);

      if (token.isCancelled) {
        throw new HttpError(ERR_CANCELLED, '请求已被调用方取消', false);
      }
      if (resp.responseCode < 200 || resp.responseCode >= 300) {
        const snippet: string = NetworkClient.snippetOf(resp.result);
        const retryable: boolean = RETRYABLE_STATUS.includes(resp.responseCode);
        throw new HttpError(resp.responseCode,
          `HTTP ${resp.responseCode}`, retryable, snippet);
      }
      const header: Record<string, string> = (resp.header ?? {}) as Record<string, string>;
      return {
        code: resp.responseCode,
        data: NetworkClient.castResult<T>(resp),
        header: header,
        durationMs: Date.now() - start
      };
    } catch (e) {
      if (e instanceof HttpError) {
        throw e;
      }
      throw NetworkClient.toHttpError(e as Object);
    } finally {
      // 顺序不能反:先注销监听,再销毁实例
      client.off('headersReceive');
      client.destroy();
      token.unbind();
    }
  }

  private static snippetOf(result: string | Object | ArrayBuffer): string {
    if (typeof result === 'string') {
      return result.length > 200 ? result.substring(0, 200) : result;
    }
    if (result instanceof ArrayBuffer) {
      return `<ArrayBuffer ${result.byteLength} bytes>`;
    }
    return '<Object>';
  }

  private static castResult<T>(resp: http.HttpResponse): T {
    const resultType: http.HttpDataType = resp.resultType;
    if (resultType === http.HttpDataType.STRING) {
      const text: string = resp.result as string;
      if (text.length === 0) {
        throw new HttpError(ERR_BAD_RESPONSE, '响应体为空,与预期的 JSON 不符', true);
      }
      try {
        return JSON.parse(text) as T;
      } catch (e) {
        // 服务端可能返回 HTML 错误页,解析失败要给出可读信息而不是抛原生异常
        throw new HttpError(ERR_BAD_RESPONSE,
          '响应体不是合法 JSON,可能是网关错误页', true, NetworkClient.snippetOf(text));
      }
    }
    return resp.result as T;
  }

  /** 带退避重试的请求入口 */
  async request<T>(url: string, cfg: RequestConfig, token: CancelToken = new CancelToken()): Promise<HttpResult<T>> {
    const retryCount: number = cfg.retryCount ?? 0;
    const baseDelay: number = cfg.retryBaseDelay ?? 300;
    let lastError: HttpError | undefined = undefined;

    for (let attempt: number = 0; attempt <= retryCount; attempt++) {
      if (token.isCancelled) {
        throw new HttpError(ERR_CANCELLED, '请求已被调用方取消', false);
      }
      try {
        return await this.once<T>(url, cfg, token);
      } catch (e) {
        const err: HttpError = e as HttpError;
        lastError = err;
        const canRetry: boolean = err.retryable && attempt < retryCount;
        if (!canRetry) {
          throw err;
        }
        // 指数退避 + 抖动,避免大量客户端同一时刻集体重试
        const backoff: number = baseDelay * Math.pow(2, attempt);
        const jitter: number = Math.floor(Math.random() * baseDelay);
        console.warn(`[NetworkClient] 第 ${attempt + 1} 次失败(code=${err.code}),${backoff + jitter}ms 后重试`);
        await NetworkClient.sleep(backoff + jitter);
      }
    }
    throw lastError ?? new HttpError(-1, 'request failed', false);
  }

  /**
   * 流式下载:分片只存引用,dataEnd 时一次性合并,规避 O(n²) 拷贝
   */
  async download(
    url: string,
    onProgress: (p: DownloadProgress) => void,
    cfg: RequestConfig,
    token: CancelToken = new CancelToken()
  ): Promise<Uint8Array> {
    const client: http.HttpRequest = http.createHttp();
    token.bind(client);

    const chunks: Uint8Array[] = [];
    let totalReceived: number = 0;
    let resolveEnd: (() => void) | undefined = undefined;
    const endSignal: Promise<void> = new Promise<void>((resolve: () => void) => {
      resolveEnd = resolve;
    });

    const onDataReceive = (piece: ArrayBuffer): void => {
      const view: Uint8Array = new Uint8Array(piece);
      // 只压入引用,不做任何拷贝;合并留到 dataEnd
      chunks.push(view);
      totalReceived += view.byteLength;
    };
    const onDataReceiveProgress = (info: http.DataReceiveProgressInfo): void => {
      const total: number = info.totalSize;
      // totalSize 依赖 Content-Length,chunked 编码或压缩时可能为 0
      const percent: number = total > 0 ? Math.floor(info.receiveSize * 100 / total) : -1;
      onProgress({ receiveSize: info.receiveSize, totalSize: total, percent: percent });
    };
    const onDataEnd = (): void => {
      if (resolveEnd !== undefined) {
        resolveEnd();
        resolveEnd = undefined;
      }
    };
    const onHeadersReceive = (header: Object): void => {
      // 用于提前读取 Content-Length / 自定义业务头,同样需要 off
      console.info(`[NetworkClient] headers: ${JSON.stringify(header)}`);
    };

    client.on('dataReceive', onDataReceive);
    client.on('dataReceiveProgress', onDataReceiveProgress);
    client.on('dataEnd', onDataEnd);
    client.on('headersReceive', onHeadersReceive);

    try {
      const requestUrl: string = NetworkClient.appendQuery(url, cfg.queryParams);
      // 下载选项:不设置响应体类型,超时按需放大 readTimeout
      const options: http.HttpRequestOptions = NetworkClient.buildOptions(cfg, true);
      const code: number = await client.requestInStream(requestUrl, options);
      if (code < 200 || code >= 300) {
        throw new HttpError(code, `HTTP ${code}`, RETRYABLE_STATUS.includes(code));
      }
      // requestInStream 只给出响应码,必须等 dataEnd 才算收完;取消时也要能退出
      await Promise.race([endSignal, token.waitCancelled()]);
      if (token.isCancelled) {
        throw new HttpError(ERR_CANCELLED, '下载已被调用方取消', false);
      }
      // 总长度已知,一次性合并:整段只有一次 O(n) 搬运
      const merged: Uint8Array = new Uint8Array(totalReceived);
      let offset: number = 0;
      for (const chunk of chunks) {
        merged.set(chunk, offset);
        offset += chunk.byteLength;
      }
      return merged;
    } finally {
      // 先 off 再 destroy,顺序反了会残留监听
      client.off('dataReceive');
      client.off('dataReceiveProgress');
      client.off('dataEnd');
      client.off('headersReceive');
      client.destroy();
      token.unbind();
      chunks.length = 0;
    }
  }
}

调用侧的使用示例,覆盖三种典型场景。

// NetworkUsage.ets
import { http } from '@kit.NetworkKit';
import { NetworkClient, CancelToken, HttpError, DownloadProgress, ERR_CANCELLED } from './NetworkClient';

interface WeatherData {
  city: string;
  temperature: number;
  updatedAt: string;
}

const client: NetworkClient = new NetworkClient();

/** 场景一:短小 GET,参数走 queryParams 而不是 extraData */
async function fetchWeather(city: string, token: CancelToken): Promise<WeatherData | undefined> {
  try {
    const res = await client.request<WeatherData>('https://api.example.com/v1/weather', {
      method: http.RequestMethod.GET,
      queryParams: { city: city, unit: 'celsius' },
      usingCache: false,        // 天气有时效性,不能命中缓存
      connectTimeout: 3000,
      readTimeout: 8000,
      totalBudget: 10000,
      retryCount: 1
    }, token);
    console.info(`[weather] 耗时 ${res.durationMs}ms`);
    return res.data;
  } catch (e) {
    const err: HttpError = e as HttpError;
    if (err.code === ERR_CANCELLED) {
      console.info('[weather] 已取消,不提示用户');
      return undefined;
    }
    console.error(`[weather] 失败 code=${err.code} msg=${err.message} raw=${err.rawSnippet}`);
    return undefined;
  }
}

/** 场景二:POST JSON + 退避重试,写操作通过幂等键保证可重试 */
async function submitOrder(orderId: string, payload: object): Promise<boolean> {
  const token: CancelToken = new CancelToken();
  try {
    const res = await client.request<object>('https://api.example.com/v1/orders', {
      method: http.RequestMethod.POST,
      header: {
        'Content-Type': 'application/json',
        'Idempotency-Key': orderId
      },
      body: payload,
      connectTimeout: 8000,
      readTimeout: 30000,       // 写操作放大总预算
      totalBudget: 45000,
      retryCount: 2,
      retryBaseDelay: 400
    }, token);
    return res.code === 201 || res.code === 200;
  } catch (e) {
    const err: HttpError = e as HttpError;
    console.error(`[order] 提交失败 code=${err.code} msg=${err.message}`);
    return false;
  }
}

/** 场景三:流式下载 + 进度 + 取消,大文件把 readTimeout 设为 0 并靠 totalBudget 兜底 */
async function downloadResource(url: string, token: CancelToken): Promise<Uint8Array | undefined> {
  try {
    const data: Uint8Array = await client.download(url, (p: DownloadProgress) => {
      if (p.percent >= 0) {
        console.info(`[download] ${p.percent}% (${p.receiveSize}/${p.totalSize})`);
      } else {
        // totalSize 未知时只报已下载量,避免用 0 做除数
        console.info(`[download] 已下载 ${(p.receiveSize / 1024 / 1024).toFixed(1)} MB`);
      }
    }, {
      method: http.RequestMethod.GET,
      usingCache: false,
      connectTimeout: 10000,
      readTimeout: 0,           // 大文件不设总时长上界
      totalBudget: 600000       // 10 分钟墙钟兜底,到点强制中断
    }, token);
    console.info(`[download] 完成,共 ${data.byteLength} 字节`);
    return data;
  } catch (e) {
    const err: HttpError = e as HttpError;
    if (err.code === ERR_CANCELLED) {
      return undefined;
    }
    console.error(`[download] 失败 code=${err.code} msg=${err.message}`);
    return undefined;
  }
}

/** 页面侧:把取消粒度控制在实例上,aboutToDisappear 时中断在途请求 */
@Entry
@Component
struct DownloadPage {
  private token: CancelToken = new CancelToken();

  aboutToDisappear(): void {
    // 只取消本页面持有的请求,避免误伤全局缓存任务
    this.token.cancel();
  }

  build() {
    Column({ space: 12 }) {
      Button('开始下载')
        .onClick(() => {
          this.token = new CancelToken();
          downloadResource('https://cdn.example.com/app/resources.zip', this.token);
        })
      Button('取消下载')
        .onClick(() => this.token.cancel())
    }
    .width('100%')
    .padding(16)
  }
}

配套的两份声明与配置。

// src/main/module.json5 片段
{
  "module": {
    "requestPermissions": [
      {
        // system_grant 权限:安装即授予,无需 requestPermissionsFromUser
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}
// src/main/resources/base/profile/network_config.json
{
  "network-security-config": {
    "base-config": {
      // 生产环境默认禁止明文
      "cleartextTrafficPermitted": false
    },
    "domain-config": [
      {
        // 优先级:component-config > domain-config > base-config
        "cleartextTrafficPermitted": true,
        "domains": [
          { "include-subdomains": true, "name": "internal.example.com" },
          { "include-subdomains": true, "name": "10.0.0.1" }
        ]
      }
    ]
  }
}

配置改完记得重新编译打包,热重载不一定生效。排查时先分层:HTTPS 全通、HTTP 全挂 一定是 network_config.json 层的问题,与超时、缓存、重试无关。

总结

回到最初的问题「如何设置请求超时时间」,答案不该只是「配 connectTimeoutreadTimeout」这两句,而应该是一次认知升级。

第一,先纠正三处 API 错误。 expectTimeout 不是 HttpRequestOptions 的字段,写它会编译报错,真正存在的只有 connectTimeoutreadTimeout,默认值都是 60000ms(不是 5000ms)。RequestMethod字符串枚举'GET''POST'…),不存在 0~7 的序号映射,别拿数字去比较或落库。

第二,也是最核心的一条:readTimeout 是「从请求开始到请求结束的总时长」上界,涵盖 DNS 解析、连接建立、数据传输的全过程。由此直接推出三个结论:

  • connectTimeout 必须严格小于 readTimeout,否则就是自相矛盾的配置——建连阶段永远跑不满就被 read 超时打断。合理区间是 readTimeout 的 30%~50%。
  • 要给长耗时请求留时间,要放大的是 readTimeout,不是 connectTimeout。「连接调到 120 秒、读取保持 15 秒」的结果必然是「连得快、下载必定断」。
  • readTimeout: 0 表示不超时,适合大文件下载,但必须配业务层墙钟兜底,否则网络假死时请求会永久挂住。

第三,流式下载的分片累积必须避免 O(n²)。 回调里只做 O(1) 的事(push 引用、累加长度),在 dataEnd 里预分配总长度并一次性合并。任何「每片都重新分配 + 搬运历史数据」的写法,都会让累计拷贝量达到文件大小的平方量级。

第四,资源释放的顺序是「先 offdestroy,且只要用了 on 就必须有成对的 off(包括容易漏掉的 headersReceive)。最稳的结构是 ontry 前、全部 off + destroyfinally 里。另外记住 http 没有独立 cancel 接口,中断只能靠 destroy(),且调用方要能区分「超时失败」与「主动取消」,别让取消触发无意义的重试和报错提示。

第五,默认值陷阱要主动反转。 usingCache 默认 true,业务数据必须显式关掉,否则会出现「服务端改了、客户端没变」的诡异现象;GET 不要用 extraData 传参(那是请求体,不会被拼进 URL),自己编码拼串或用 API 26 起的 queryParamsresult 类型取决于 expectDataTypeJSON.parse 必须 try/catch,否则服务端返回 HTML 错误页时会掩盖真实状态码。

第六,配置文件与代码配置不在同一层。 network_config.json准入层cleartextTrafficPermitted 管明文),代码里的 HttpRequestOptions行为层(超时、缓存、协议、代理)。代码无法覆盖明文准入策略,内部的层级优先级是 component-config > domain-config > base-config。看到「HTTPS 通、HTTP 不通」,直接去查配置文件,不要动超时。

最后给一份可以直接抄的落地清单:

  1. 一次请求一个 http.createHttp() 实例,finallyoffdestroy,不做全局复用、不做同实例并发。
  2. 封装层把 connectTimeoutreadTimeout 的默认值写死,并在 connectTimeout >= readTimeout 时自动收紧并打日志。
  3. 业务数据一律 usingCache: false;只有静态资源才考虑开缓存。
  4. 每个请求都设 totalBudget 墙钟兜底,大文件下载尤其要设。
  5. 重试按错误码分层:网络类/超时类/5xx/408/429 才重试,参数类、鉴权类、证书类不重试;用指数退避加抖动,写操作配幂等键。
  6. 记录每个请求的 durationMs 并上报。当它长期贴着 readTimeout 时,说明该接口已经在超时边缘——这是最便宜的预警信号,比等用户投诉早得多。

把这几条做扎实,Request timeout 就不会再是一个只能靠猜的报错,而是一个能立刻定位到「哪一层、哪一段预算」的明确信号。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐