鸿蒙 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:

参数默认值语义特别注意
connectTimeout60000msTCP+TLS 建连阶段单位毫秒,需 uint32 范围内整数
readTimeout60000ms建连完成后的读取阶段传 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。

最后是几个「不是默认值但一搜就撞上」的错误码,它们都是被框架主动拦下的请求,看起来像网络故障:

错误码含义适用版本
2300997Cleartext traffic not permitted(明文 HTTP 被拦截)API 18+
2300998It is not allowed to access this domain(域名未在允许列表)API 12+
2300996The request was intercepted by the HTTP global interceptorAPI 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%')
  }
}

验证步骤(每条都能在真机上复现):

  1. 5MB 上限:本地起一个返回 8MB JSON 的服务,maxLimit 不配置,观察 catch 里的 code(应为体量类错误码 2300063);再把 maxLimit 设为 20 * 1024 * 1024,同一次请求通过。
  2. 一实例一请求:把 requestJson 中的 createHttp() 提到模块顶层(全局复用一次),并发发起 20 个请求,观察是否出现响应错位;恢复封装后不再出现。
  3. 缓存默认值:对同一 GET 接口,服务端每次返回递增计数;usingCache 保持默认(true)时重复请求拿到同一个数,改成 false 后每次递增。
  4. 404 不进 catch:请求一个不存在的路径,确认没有异常抛出,而 responseCode 为 404 —— 封装里若漏掉这一步,UI 会一直停在加载态。
  5. 明文拦截:把接口换成 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 都打出来。这两条改动成本极低,但能立刻把「偶发失败」变成「可定位的错误」。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐