HarmonyOS 网络请求超时与流式下载:readTimeout 到底管多久
HarmonyOS 网络请求超时与流式下载:readTimeout 到底管多久
前言
@ohos.net.http 大概是 ArkTS 生态里被写得最多、也错得最多的一个模块。它表面上只有「建实例、填 options、发请求、destroy」四步,门槛低到几乎是每个新手第一个跑通的网络调用;但真正决定线上成败的几个语义,恰恰藏在文档最容易一扫而过的那几行里。
最典型的是超时。绝大多数人对 connectTimeout 和 readTimeout 的理解停留在字面:一个是「连的时候超时」,一个是「读的时候超时」。这个理解听起来天经地义,写出来的代码也不会报错、不会崩,甚至单元测试都能过——直到某天你调大 connectTimeout 去抢救一个慢接口,发现它比以前更早失败;或者某个几百兆的资源包下载,连接明明成功了,进度条却总在固定时刻断掉。这类问题的排查成本极高,因为错误信息只有一句 Request timeout,谁看都像是服务端慢。
除此之外还有一串「默认值陷阱」和「使用姿势陷阱」:缓存默认是开的、GET 用 extraData 传参不会生效、流式下载的分片累积写法不对会把内存和 CPU 一起打满、事件监听不注销会泄漏。它们零星地散在文档各处,单看每一条都不难,凑在一起就变成了「网络模块玄学」。
这篇文章从论坛上一个具体的提问切入,先把三处流传很广的 API 级错误纠正干净,再重点讲透 readTimeout 的真实语义,然后沿「请求方式 → 配置 → 流式下载 → 取消与释放 → 错误码 → 配置文件」这条链路,给出一套可以直接落在生产代码里的完整封装。
问题描述
论坛原帖的问题本身很朴素,楼主问了两个问题:
- HarmonyOS 的
@ohos.net.http模块支持哪几种请求方式? - 如何设置请求超时时间?
这是标准的入门问题,官方文档里就有答案:支持 GET、POST、OPTIONS、HEAD、PUT、DELETE、TRACE、CONNECT 八种方法,小数据量用 HttpRequest.request,大文件上传下载且关心进度用 HttpRequest.requestInStream,超时在 HttpRequestOptions 里通过 readTimeout 和 connectTimeout 配置,默认都是 60000ms。
问题出在回帖上。几个回答里混进了三处硬伤,而且它们的危害程度完全不同:
- 第一处是编译期错误。有回答给出了
expectTimeout: 10000,并声称这是「HttpRequestOptions的属性,默认值 5000ms」。ArkTS 的对象字面量只允许已知属性,写这个字段会直接编译报错;而且默认值也不是 5 秒,readTimeout和connectTimeout的官方默认值都是 60000ms。这处错误好在「错得很响」,编译不过就改掉了。 - 第二处是理解偏差。有回答把
RequestMethod描述成OPTIONS(0)、GET(1)、HEAD(2)、POST(3)……一直排到CONNECT(7),即「枚举序号」。实际它是字符串枚举,枚举值就是'OPTIONS'、'GET'这样的大写方法名字符串。这处错误有一个潜伏期:如果你拿数字去比较、落库或者做协议映射,代码不会报错,只会在某个判断分支上悄悄走错。 - 第三处是语义误解,也是本文的重点。多个回答都把
readTimeout描述为「连接成功后读取响应数据的超时」,把connectTimeout描述为「TCP 建立连接的超时」,于是很自然地给出「连接阶段 8 秒、读取阶段 15 秒」这类配置。这正是导致前述两类「玄学故障」的认知根源。
所以真正需要回答的不是「有几种方法、怎么设超时」这两个原地问题,而是三个更深的问题:
readTimeout计时的起止点到底在哪里?它管的是哪一段?- 基于这个语义,
connectTimeout和readTimeout之间必须满足什么关系?大文件下载该怎么配? - 流式下载、取消、重试、资源释放这几个动作,各自有哪些「顺序错了就出问题」的细节?
细节解析
1. 先纠正三处 API 级错误
逐条说清,因为后面所有结论都建立在这三条之上。
(1)expectTimeout 不是 HttpRequestOptions 的字段。
HttpRequestOptions 里与超时相关的字段只有两个:connectTimeout 和 readTimeout,两者的默认值都是 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」,而不是「大于」。典型配置是让 connectTimeout 占 readTimeout 的 30%~50%:总预算 20 秒时建连给 5~8 秒,这样建连慢到离谱时能提前失败换取更快的重试,而正常请求又能用满剩余预算。
推论二:要给长耗时请求留时间,要放大的是 readTimeout。
这是大文件下载场景最常见的错误配置。很多人遇到下载失败,第一反应是把 connectTimeout 调大:
// 错误示范:连上了,但下载必然超时
const options: http.HttpRequestOptions = {
connectTimeout: 120000, // 以为调大这个就能下载更久
readTimeout: 15000 // 总预算仍然只有 15 秒
};
结果就是「连接秒开,进度条走到 15 秒必断」。因为决定下载能跑多久的是 readTimeout,connectTimeout 只影响建连阶段。大文件下载必须放大 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 调到比心跳间隔大一点」来解决,因为总时长是无界的,调多大都会到。
把上面几条整理成一张配置矩阵,落地时可以直接对照:
| 场景 | connectTimeout | readTimeout | 要点 |
|---|---|---|---|
| 普通 JSON 接口 | 3000~8000 | 15000~20000 | connect 控制在 read 的 30%~50% |
| 首屏关键接口(要快失败) | 2000~3000 | 5000~8000 | 短超时 + 业务层降级兜底 |
| 弱网/移动网络下的写操作 | 8000 | 30000~60000 | 写操作要留足预算,配合幂等重试 |
| 大文件下载(进度可感知) | 10000 | 0 或 600000 | 放大 readTimeout,用业务层计时器兜底 |
| 大文件上传 | 10000 | 0 或 600000 | 同上,且要处理分片重传 |
| SSE / 长轮询 | 8000 | 0 | 心跳与重连自己做,别依赖 readTimeout |
3. 请求方式与接口选择
http.RequestMethod 提供八种方法:GET、POST、PUT、DELETE、HEAD、OPTIONS、TRACE、CONNECT,默认 GET。从 API 26 开始新增 PATCH——注意 PATCH 在 REST 语义里非常常用(局部更新),如果你的 SDK 版本低于 26 而接口又只提供 PATCH,只能降级用 PUT 全量提交,或者请服务端补一个 POST 别名端点。
需要注意,这八种方法是 HTTP 协议层的全集,不代表服务端都支持。实际项目里真正用到的通常就是 GET、POST、PUT、DELETE 四种,HEAD 适合做「只探测资源是否存在/大小」的轻量检查(响应无 body,带宽开销极小),OPTIONS 一般用于 CORS 预检或探测服务端能力。
接口层面的选择很清晰:
- 数据量小、要一次性拿到完整响应体 →
HttpRequest.request。返回值是HttpResponse,result里是完整响应体,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 的语义是请求体。GET 和 HEAD 在协议上没有请求体,所以 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,包含 receiveSize 和 totalSize。这里要注意: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-Length、Content-Type、自定义业务头,但同样需要 off('headersReceive')。最稳的结构是把 on 的注册放在 try 之前(或 try 开头)、把全部 off + destroy 放在 finally 里,这样无论成功、失败还是异常,资源都一定被回收。
关于取消,有一个必须知道的事实:http 模块没有独立的 cancel 接口。要中断一个进行中的请求,唯一手段就是 destroy(),代价是它以错误回调结束——你会收到一个错误,而不是一个「已取消」的正常返回。这带来两个实践要求:
- 业务层要能区分「超时失败」和「主动取消」。否则用户划走页面导致的取消会触发一次无意义的重试,甚至弹出一个错误提示。做法是在封装里维护一个取消标记,取消发生后识别到标记就直接结束、不重试、不报错。
- 不要随意
destroy()一个还需要的结果的请求。典型误用是在页面的aboutToDisappear里无条件销毁所有在途请求;如果这个请求的目的地是全局缓存或下一屏要用的数据,你就会白跑一次。取消的粒度应该是「实例级」的,由调用方持有并决定何时取消。
7. 超时兜底、错误码与重试分层
超时配置解决的是「框架认识到的超时」,但还有一层是「框架没认识到、业务认为已经超时」。典型场景:readTimeout 设成了 0,或者 DNS 阶段卡在系统解析器里连计时都没开始。所以业务层需要自己的兜底计时器——这也是我前面反复强调的「readTimeout: 0 必须配兜底」的原因。
兜底计时器要注意两点:一是它应该是墙钟时间(从发起请求开始算),而不是事件间隔;二是它触发时必须同时做两件事——destroy() 中断底层请求、用一个明确的业务错误 reject 掉外层 Promise。只 reject 不 destroy 的话,底层请求还在跑,连接和实例都没释放,属于「假取消」。
错误码层面,重试策略必须分层,不能一视同仁。常见的 http 错误码及处置:
| 错误码 | 含义 | 是否值得重试 |
|---|---|---|
| 2300003 | URL 格式错误 | 否,参数问题 |
| 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.INTERNET 是 system_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 层的问题,与超时、缓存、重试无关。
总结
回到最初的问题「如何设置请求超时时间」,答案不该只是「配 connectTimeout 和 readTimeout」这两句,而应该是一次认知升级。
第一,先纠正三处 API 错误。 expectTimeout 不是 HttpRequestOptions 的字段,写它会编译报错,真正存在的只有 connectTimeout 和 readTimeout,默认值都是 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 里预分配总长度并一次性合并。任何「每片都重新分配 + 搬运历史数据」的写法,都会让累计拷贝量达到文件大小的平方量级。
第四,资源释放的顺序是「先 off 再 destroy」,且只要用了 on 就必须有成对的 off(包括容易漏掉的 headersReceive)。最稳的结构是 on 在 try 前、全部 off + destroy 在 finally 里。另外记住 http 没有独立 cancel 接口,中断只能靠 destroy(),且调用方要能区分「超时失败」与「主动取消」,别让取消触发无意义的重试和报错提示。
第五,默认值陷阱要主动反转。 usingCache 默认 true,业务数据必须显式关掉,否则会出现「服务端改了、客户端没变」的诡异现象;GET 不要用 extraData 传参(那是请求体,不会被拼进 URL),自己编码拼串或用 API 26 起的 queryParams;result 类型取决于 expectDataType,JSON.parse 必须 try/catch,否则服务端返回 HTML 错误页时会掩盖真实状态码。
第六,配置文件与代码配置不在同一层。 network_config.json 是准入层(cleartextTrafficPermitted 管明文),代码里的 HttpRequestOptions 是行为层(超时、缓存、协议、代理)。代码无法覆盖明文准入策略,内部的层级优先级是 component-config > domain-config > base-config。看到「HTTPS 通、HTTP 不通」,直接去查配置文件,不要动超时。
最后给一份可以直接抄的落地清单:
- 一次请求一个
http.createHttp()实例,finally里off→destroy,不做全局复用、不做同实例并发。 - 封装层把
connectTimeout和readTimeout的默认值写死,并在connectTimeout >= readTimeout时自动收紧并打日志。 - 业务数据一律
usingCache: false;只有静态资源才考虑开缓存。 - 每个请求都设
totalBudget墙钟兜底,大文件下载尤其要设。 - 重试按错误码分层:网络类/超时类/5xx/408/429 才重试,参数类、鉴权类、证书类不重试;用指数退避加抖动,写操作配幂等键。
- 记录每个请求的
durationMs并上报。当它长期贴着readTimeout时,说明该接口已经在超时边缘——这是最便宜的预警信号,比等用户投诉早得多。
把这几条做扎实,Request timeout 就不会再是一个只能靠猜的报错,而是一个能立刻定位到「哪一层、哪一段预算」的明确信号。
更多推荐

所有评论(0)