鸿蒙 HTTP 的五个默认值:5MB、60 秒、一实例一请求、缓存优先,和那个不会进 catch 的 404
鸿蒙 HTTP 的五个默认值:5MB、60 秒、一实例一请求、缓存优先,和那个不会进 catch 的 404
前言
写鸿蒙网络层的时候,最贵的 Bug 往往不是写错的代码,而是你以为的默认值和框架真实的默认值不一样。
@kit.NetworkKit 里的 http 模块只有十来个方法,看起来简单到不需要封装。但它的几个默认值分别是:
- 响应体最多接收 5MB(API 23 之前就是硬上限);
- 连接超时和读取超时都是 60 秒,而且
readTimeout传 0 是「永不超时」; - 一个
HttpRequest实例只对应一个请求,复用实例不会报错,只会串数据; usingCache默认 true,请求会优先读进程内缓存;- HTTP 404 / 500 不会让
catch分支执行,err依然是undefined。
这五条单独看都不难,但它们组合起来会产生一类极难排查的现象:「接口在 Postman 里是好的,在鸿蒙真机上偶尔失败,日志里还看不到异常」。本文把这五个默认值逐个摊开,给出一份可以直接抄进项目的 ArkTS 封装,最后附一张上线前的自查表。
问题描述
下面四个现象都来自真实的鸿蒙应用排障场景,它们的共同点是代码「看起来没错」。
现象一:大 JSON 接口在真机上「一直失败」,服务端日志显示 200。
一个返回全量商品列表的接口,测试环境数据量小,一切正常。切到预置了 3 万条数据的联调环境后,客户端持续报请求失败,服务端访问日志却是清一色的 200 OK,响应体约 8.2MB。开发者的第一反应是「网络差」,但同一台设备用浏览器打开这个接口照样能拉到数据。
现象二:并发请求偶发「串数据」。
为了省对象,有人在单例里只 createHttp() 一次,然后 Promise.all 同时发登录和拉用户资料两个请求。大多数时候正常,压测或弱网时偶发「用户资料接口拿到了登录接口的响应」。把两个请求改成串行就好,于是被归因为「鸿蒙并发有问题」。
现象三:改了后端返回,App 里三天都是旧数据。
后端修正了一个字段,抓包能看到新值,App 里依然是老值;杀掉进程重进才刷新。
现象四:接口 404 时页面一直转圈,没有任何错误提示。
try { await req.request(...) } catch (e) { showToast('网络异常') } —— 这段代码在 404 时什么都不会发生,因为请求在 HTTP 语义上是「成功完成」的,err 是 undefined,data.responseCode 才是 404,而它被忽略了。
还有一个隐藏款:内存缓慢上涨。接口功能都正常,但长时间使用后应用内存曲线上扬,destroy() 一次都没被调用过。
细节解析
一、5MB:request 的接收上限,且不是「静默截断」
先看官方对 request 接口的原文说明:
(1) 此接口仅支持接收 5MB 以内的数据,如果需要接收超过 5MB 的数据,则需主动在
HttpRequestOptions的maxLimit中进行设置,或者使用requestInStream接口发起流式请求。自 API version 23 开始,本接口支持的最大接收数据量为 50MB,API version 23 之前仍为 5MB,超过 5MB 会接收失败。
也就是说,这里有三个互相独立的数字,很容易记混:
| 位置 | 数值 | 含义 |
|---|---|---|
request 接口能力 | 5MB(API 23 起 50MB) | 不配置 maxLimit 时能收到的上限 |
maxLimit 默认值 | 5 × 1024 × 1024 = 5,242,880 字节 | 不显式配置就是 5MB |
maxLimit 最大值 | 100 × 1024 × 1024 = 104,857,600 字节 | 配置超过它也不会生效 |
关键结论是最后半句「超过 5MB 会接收失败」:它不是把 body 截断成 5MB 交给你,而是整次请求失败。所以现象一里客户端拿到的是错误对象,而服务端只看到 200 —— 因为服务端确实完整发出了响应,是客户端侧在收包阶段放弃的。
这类失败在错误码表里有对应的条目:
2300063—Maximum file size exceeded.2300018—Transferred a partial file.(传输中断,常见于超大响应或弱网)
而这张错误码表的生成规则同样写在同一页文档里:
HTTP 错误码映射关系:2300000 + curl 错误码。
这条规则非常有用,它让你不用背表也能推断含义:2300028 是 28 号 curl 错误 = 操作超时;2300006 是 6 号 = 无法解析主机名(DNS 失败);2300007 是 7 号 = 无法连接服务器(TCP 连不上,一般是端口不通或服务未启动);2300060 是 60 号 = 证书校验失败(自签证书 / 抓包代理没装根证书)。
对应的工程做法很直接:任何可能返回大 JSON 的接口,都要么显式抬 maxLimit,要么走流式。抬 maxLimit 只是把天花板从 5MB 提到你设的值,内存里仍然要一次性持有完整响应;真正的大文件(图片、安装包、日志包)应当走 requestInStream + on('dataReceive') 边收边落盘。
二、一个 HttpRequest 只对应一个请求
createHttp 的文档写得很明确:
创建一个 HTTP 请求,里面包括发起请求、中断请求、订阅/取消订阅 HTTP Response Header 事件。当发起多个 HTTP 请求时,需为每个 HTTP 请求创建对应
HttpRequest对象。每一个HttpRequest对象对应一个 HTTP 请求。说明:当该请求使用完毕时,需调用
destroy方法释放资源,否则会出现内存泄漏问题。
现象二就是违反了这一条。复用同一个实例并发发请求时,订阅类事件(headersReceive、dataReceive、dataReceiveProgress)的归属是实例级的,多个请求共用一个实例意味着事件回调无法区分是哪个请求触发的,于是出现「A 请求的响应进了 B 的回调」。它不会抛错,只会偶发串数据,这也是它比直接崩溃更危险的原因。
正确的写法是「一请求一实例一销毁」:
const req = http.createHttp();
try {
// 只发这一个请求
} finally {
req.destroy();
}
把 destroy() 放在 finally 里是硬性要求。历史代码里 destroy() 缺失的位置通常是「成功分支写了、失败分支忘了」,而失败路径恰恰是弱网下发生频率更高的那条。
三、60 秒 × 2,以及 readTimeout = 0 的陷阱
两个超时的默认值都是 60000ms:
| 参数 | 默认值 | 语义 | 特别注意 |
|---|---|---|---|
connectTimeout | 60000ms | TCP+TLS 建连阶段 | 单位毫秒,需 uint32 范围内整数 |
readTimeout | 60000ms | 建连完成后的读取阶段 | 传 0 表示不会出现超时情况 |
readTimeout: 0 是一个很容易踩的坑:它看起来像「不设置」,实际语义是「永不超时」。如果某个下载任务写了 readTimeout: 0,一旦服务端卡住不返回,这个请求会挂着直到进程退出,用户侧表现为「永远在转圈」,且不会产生任何错误日志。
对于移动端,60000ms 也是偏长的默认值——用户不会等一分钟。通常的做法是:连接超时收紧(5~10 秒,弱网下快速失败并重试),读取超时按接口分级(列表类 10~15 秒,上传/导出类 60~120 秒)。超时之后 catch 到的就是 2300028 Operation timeout,可以在 UI 上给出明确的「网络慢,请重试」而不是笼统的「网络异常」。
四、usingCache 默认 true:请求会优先读缓存
usingCache… 是否使用缓存,true 表示请求时优先读取缓存,false 表示不使用缓存;默认为 true,请求时优先读取缓存。缓存跟随当前进程生效,新缓存会替换旧缓存。
现象三的根因就在这一行。GET 请求默认优先读缓存,缓存生命周期跟随当前进程,所以「杀掉进程再进就刷新了」。排查时它极具欺骗性,因为抓包工具抓的是真实网络请求,你会看到新数据确实返回了,但代码拿到的是缓存。
有两个开关,语义不同,不要混用:
usingCache: false—— 请求级,关闭这一个请求的缓存读取,影响面最小;- 请求头
'Cache-Control': 'no-cache'—— 语义上更贴近「我要最新的业务数据」,且对服务端/CDN 链路也生效。
业务上「宁可多一次请求也不能给旧数据」的接口(订单状态、余额、消息未读数),建议两者都加;对静态配置类接口保留默认的 usingCache: true 反而能省流量。
五、404 不会进 catch,expectDataType: OBJECT 还有 64K 上限
request 的 callback 说明是「当请求成功时,回调内容是 HttpResponse,请求失败时为 undefined」。这里的「成功」指的是网络层完成:TCP 通、TLS 通、收到了 HTTP 响应报文。至于响应是 200 还是 404、500,都属于「成功拿到响应」。所以:
const resp = await req.request(url, opt); // 404 时不会 reject
if (resp.responseCode !== http.ResponseCode.OK) {
// 这里才是业务判定的唯一入口
}
catch 只负责传输层错误:DNS 失败、连不上、超时、证书错、被拦截。
顺带一个高频坑:把 expectDataType 设成 http.HttpDataType.OBJECT 后,文档限定「当指定其类型为 Object 时,最大长度为 65536 字符数」。也就是说响应超过 64K 字符时用 OBJECT 类型解析会出问题,而多数列表接口都会超过这个量级。稳妥做法是始终取 STRING 再自己 JSON.parse,既能控制错误处理,也避开了这条长度限制。
另外一个默认值细节值得记住:header 的默认 content-Type 与请求方法有关 —— 当请求方式为 POST/PUT/DELETE 时为 application/json,其他情况(包括 GET)为 application/x-www-form-urlencoded。所以 GET 带 extraData 时,Object 会被拼接到 URL 上;如果要传 JSON body 的语义,必须显式写 header。
最后是几个「不是默认值但一搜就撞上」的错误码,它们都是被框架主动拦下的请求,看起来像网络故障:
| 错误码 | 含义 | 适用版本 |
|---|---|---|
2300997 | Cleartext traffic not permitted(明文 HTTP 被拦截) | API 18+ |
2300998 | It is not allowed to access this domain(域名未在允许列表) | API 12+ |
2300996 | The request was intercepted by the HTTP global interceptor | API 26.0.0+ |
2300997 是内网联调阶段最常见的「莫名其妙失败」:接口地址是 http://,在低版本设备上能跑,升到 API 18+ 的设备上直接失败,而日志里只有一串数字。
示例代码
下面是一个可以直接放进项目的封装。它把上面五个默认值全部显式化,并且保证「一请求一实例一销毁」、「responseCode 才是业务判据」、「错误码带语义」。
// entry/src/main/ets/common/NetClient.ets
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
export interface NetOptions {
method?: http.RequestMethod;
header?: Record<string, string>;
body?: string | Object | ArrayBuffer;
timeoutMs?: number;
maxBytes?: number;
useCache?: boolean;
}
export class NetError extends Error {
code: number;
detail: string;
constructor(code: number, detail: string) {
super(detail);
this.code = code;
this.detail = detail;
}
}
/** 把 2300000 + curl 码翻译成能直接贴进日志的中文 */
function describe(code: number): string {
if (code === 2300006) {
return '域名解析失败(DNS)';
}
if (code === 2300007) {
return '连接服务器失败(端口/服务未启动)';
}
if (code === 2300028) {
return '请求超时';
}
if (code === 2300060) {
return '证书校验失败(自签证书或抓包代理)';
}
if (code === 2300063) {
return '响应体超过 maxLimit(默认 5MB)';
}
if (code === 2300997) {
return '明文 HTTP 被拦截(API 18+)';
}
if (code === 2300998) {
return '域名不在允许访问列表(API 12+)';
}
return '网络错误';
}
/**
* 一请求一实例一销毁的 JSON 请求。
* 全程不使用 expectDataType=OBJECT,规避 65536 字符上限。
*/
export async function requestJson<T>(url: string, opt?: NetOptions): Promise<T> {
const req = http.createHttp(); // 每个请求独立实例
const timeout: number = opt?.timeoutMs ?? 15000;
// 默认给到 20MB,仍然显式设置,避免依赖 5MB 默认值
const maxBytes: number = opt?.maxBytes ?? 20 * 1024 * 1024;
const headers: Record<string, string> = opt?.header ??
{ 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' };
try {
const resp: http.HttpResponse = await req.request(url, {
method: opt?.method ?? http.RequestMethod.GET,
header: headers,
extraData: opt?.body,
expectDataType: http.HttpDataType.STRING, // 固定 STRING
usingCache: opt?.useCache ?? false, // 默认不吃缓存
connectTimeout: Math.min(timeout, 10000), // 建连快速失败
readTimeout: timeout, // 绝不传 0
maxLimit: maxBytes
});
// 唯一业务判据:HTTP 状态码
if (resp.responseCode !== http.ResponseCode.OK) {
throw new NetError(resp.responseCode, `HTTP ${resp.responseCode} ${url}`);
}
const text: string = resp.result as string;
if (text.length === 0) {
throw new NetError(-1, `响应体为空 ${url}`);
}
// 大响应在 OBJECT 模式下会被 65536 字符限制,这里自己解析
return JSON.parse(text) as T;
} catch (e) {
if (e instanceof NetError) {
throw e;
}
const err = e as BusinessError;
console.error(`[NET] ${url} code=${err.code} msg=${describe(err.code)}`);
throw new NetError(err.code ?? -1, describe(err.code ?? -1));
} finally {
req.destroy(); // 成功失败都要销毁
}
}
大文件下载走流式,边收边落盘,内存占用与响应体大小无关:
// entry/src/main/ets/common/NetDownload.ets
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';
import { NetError } from './NetClient';
export async function downloadToFile(url: string, savePath: string,
maxBytes: number = 200 * 1024 * 1024): Promise<number> {
const req = http.createHttp();
let fd: number = -1;
let received: number = 0;
let aborted: boolean = false;
try {
fd = fileIo.openSync(savePath, fileIo.OpenMode.CREATE |
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC).fd;
req.on('dataReceive', (chunk: ArrayBuffer) => {
if (aborted) {
return;
}
if (received + chunk.byteLength > maxBytes) {
aborted = true; // 先标记,再销毁,避免半包写入
return;
}
fileIo.writeSync(fd, chunk); // 边收边写,不囤在内存里
received += chunk.byteLength;
});
// requestInStream 返回的是 HTTP 响应码,不是字节数
const code: number = await req.requestInStream(url, {
method: http.RequestMethod.GET,
connectTimeout: 10000,
readTimeout: 120000
});
if (code !== http.ResponseCode.OK) {
throw new NetError(code, `HTTP ${code} ${url}`);
}
if (aborted) {
throw new NetError(2300063, `超过本地限额 ${maxBytes} 字节`);
}
return received;
} finally {
req.off('dataReceive');
req.destroy();
if (fd >= 0) {
fileIo.closeSync(fd);
}
}
}
页面里调用时,把「网络层错误」和「业务错误」分开呈现,用户才知道该不该重试:
import { requestJson, NetError } from '../common/NetClient';
interface GoodsList {
total: number;
items: string[];
}
@Entry
@Component
struct GoodsPage {
@State tip: string = '加载中…';
@State items: string[] = [];
aboutToAppear(): void {
this.load();
}
async load(): Promise<void> {
try {
const data = await requestJson<GoodsList>(
'https://api.example.com/v1/goods?page=1',
{ timeoutMs: 12000, maxBytes: 30 * 1024 * 1024 }
);
this.items = data.items;
this.tip = `共 ${data.total} 条`;
} catch (e) {
const err = e as NetError;
// 2300063 说明数据量超出预算 → 提示改分页,而不是「网络异常」
this.tip = err.code === 2300063 ? '数据量过大,请缩小分页' : `加载失败(${err.code})`;
}
}
build() {
Column({ space: 8 }) {
Text(this.tip).fontSize(14).fontColor('#666')
List() {
ForEach(this.items, (it: string) => {
ListItem() {
Text(it).fontSize(16).width('100%')
}
}, (it: string) => it)
}
.layoutWeight(1)
}
.padding(16)
.width('100%')
.height('100%')
}
}
验证步骤(每条都能在真机上复现):
- 5MB 上限:本地起一个返回 8MB JSON 的服务,
maxLimit不配置,观察catch里的code(应为体量类错误码2300063);再把maxLimit设为20 * 1024 * 1024,同一次请求通过。 - 一实例一请求:把
requestJson中的createHttp()提到模块顶层(全局复用一次),并发发起 20 个请求,观察是否出现响应错位;恢复封装后不再出现。 - 缓存默认值:对同一 GET 接口,服务端每次返回递增计数;
usingCache保持默认(true)时重复请求拿到同一个数,改成false后每次递增。 - 404 不进 catch:请求一个不存在的路径,确认没有异常抛出,而
responseCode为 404 —— 封装里若漏掉这一步,UI 会一直停在加载态。 - 明文拦截:把接口换成
http://地址,在 API 18+ 设备上请求,确认得到2300997。
总结
鸿蒙 http 模块「难」不在 API 数量,而在默认值分布在文档的五个不同段落里。把本文的结论压缩成一张自查表:
| 关注点 | 框架默认 | 你该显式做的事 |
|---|---|---|
| 响应体上限 | request 5MB(API 23 起 50MB);maxLimit 默认 5MB、最大 100MB | 大接口抬 maxLimit;超大文件用 requestInStream |
| 实例复用 | 一个 HttpRequest 对应一个请求 | 每请求 createHttp(),finally 里 destroy() |
| 超时 | connectTimeout/readTimeout 均 60000ms,readTimeout: 0 = 永不超时 | 建连 5~10s,读取按接口分级,严禁传 0 |
| 缓存 | usingCache: true,优先读缓存,跟随进程 | 实时数据关缓存 + Cache-Control: no-cache |
| 错误判定 | 404/500 不进 catch,err 为 undefined | 以 responseCode 为唯一业务判据 |
| 返回类型 | expectDataType 不设置即 STRING;OBJECT 有 65536 字符上限 | 固定 STRING + 自己 JSON.parse |
| 错误码 | 2300000 + curl 码 | 建一张 6~8 条的常见码中文对照表进日志 |
一句话概括:catch 只负责「有没有连上」,responseCode 负责「业务对不对」,maxLimit 负责「装不装得下」,destroy 负责「漏不漏」。 这四件事分清楚了,网络层的偶发问题会少掉一大半。
如果你的应用还在用「一个全局 HttpRequest 实例 + 默默依赖默认值」的写法,建议先做两件事:给每个请求包上 finally destroy,然后在日志里把 err.code 和 resp.responseCode 都打出来。这两条改动成本极低,但能立刻把「偶发失败」变成「可定位的错误」。
更多推荐


所有评论(0)