HTTP 接口测试基础与调试¶
接口测试的失败,多数不是“业务逻辑写错了”,而是请求没按服务端期望的形态到达:Content-Type 少了 charset、body 被网关吞掉、URL 被编码两次、gzip 没解、Cookie 没带上。下面用能直接粘贴执行的命令和真实报文,把这些点逐个钉死。
一、一次真实请求的完整报文¶
curl -v 抓下来的请求(证书输出已省略):
POST /api/v1/orders?from=web HTTP/1.1
Host: api.example.com
User-Agent: curl/8.5.0
Accept: */*
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMDEsImV4cCI6MTc2MTEwMDAwMH0.9xkQ
Idempotency-Key: 0b6f3a52-2f1e-4b9a-9d3e-6f0f9a1c7d21
Content-Length: 34
{"skuId":1001,"count":2}
对应响应:
HTTP/1.1 201 Created
Server: nginx/1.24.0
Date: Wed, 22 Oct 2026 03:12:41 GMT
Content-Type: application/json;charset=UTF-8
Content-Length: 96
Location: /api/v1/orders/9001
ETag: "8a1c9f0e"
X-Request-Id: 7f3c9a1e-5b2d-4c8a-9e11-2f6b0d3a4c55
{"code":0,"data":{"orderId":9001,"status":"PENDING","amount":"100.00"}}
报文本身的几个硬约束,测试时可以直接当断言:
- 请求行与每个 Header 行以
CRLF结束,Header 与 body 之间必须有一个空行。手工拼报文时只写\n,部分老网关会返回400 Bad Request。 Content-Length与Transfer-Encoding: chunked互斥。两者同时出现时,按 RFC 9112 应拒绝或忽略 Content-Length;如果服务端两个都信,就可能被请求走私利用(这是安全测试关注点,不是功能问题)。Host在 HTTP/1.1 里必填。直接用 IP 访问且不带 Host,Nginx 会落到 default_server,返回的不是你测的站点。
二、方法与幂等性¶
| 方法 | 安全(不改状态) | 幂等 | 常规 body | 典型误用 |
|---|---|---|---|---|
| GET | 是 | 是 | 不应有 | 用 GET 做删除、GET 带 body 传参数 |
| HEAD | 是 | 是 | 不应有 | 用它判断资源存在,却忽略 200 也可能无 body |
| POST | 否 | 否 | 有 | 重复提交不判重,直接生成两单 |
| PUT | 否 | 是 | 有 | 只传要改的字段,结果把其他字段清空 |
| PATCH | 否 | 不一定 | 有 | 用 JSON Merge Patch 却按 PUT 语义实现 |
| DELETE | 否 | 是 | 可无 | 第二次删除返回 500 而不是 204/404 |
POST 的“非幂等”可以用 Idempotency-Key 补救。验证脚本(注意 --data-raw,不要用 -X POST):
KEY=$(python -c "import uuid;print(uuid.uuid4())")
for i in 1 2; do
curl -sS https://api.example.com/api/v1/orders \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $KEY" \
--data-raw '{"skuId":1001,"count":2}' \
| jq -c '{code, orderId: .data.orderId}'
done
# {"code":0,"orderId":9001}
# {"code":0,"orderId":9001}
判定标准:两次 orderId 必须相同;若第二次返回 409 CONFLICT 且带首次结果,也算合格实现,但要在用例里写清楚预期。真正不合格的是生成 9001 和 9002 两单,或第二次返回 500。
三、状态码的真实语义¶
| 状态码 | 真实语义 | 容易误判的点 |
|---|---|---|
| 301 | 永久重定向,缓存 | 浏览器/CDN 会长期缓存,测试环境切换域名后仍跳旧地址 |
| 302 | 临时重定向 | 历史上大量客户端把 POST 改成 GET,见下方实测 |
| 303 | See Other,明确要求改用 GET | 支付回跳常用,不能当 302 处理 |
| 304 | 协商缓存命中,无 body | 有的网关仍塞 Content-Length: 0 之外的 body,导致客户端解析错位 |
| 307 | 临时重定向,保持方法与 body | 与 302 的差别只在“是否保持方法” |
| 308 | 永久重定向,保持方法与 body | 被缓存后很难回退 |
| 401 | 未认证:没带凭证或凭证无效 | 返回 401 却没带 WWW-Authenticate,不符合 RFC 9110 |
| 403 | 已认证但无权限 | 与 404 混用做资源隐藏时,要注意越权用例的预期 |
| 409 | 状态冲突:重复提交、版本冲突、资源被占用 | 有的实现拿它当参数错误用 |
| 415 | 媒体类型不支持 | 绝大多数是 Content-Type 缺失或写错 |
| 422 | 语义校验失败(格式对、值不合法) | Spring 默认对 @Valid 失败返回 400,不是 422 |
| 429 | 限流 | 必须看 Retry-After 与限流窗口,不能只断言 429 |
| 502 | 网关从上游拿到非法响应/连接被重置 | 应用日志里可能一条错误都没有 |
| 504 | 网关等待上游超时 | 与上游自身 500 的区别是“谁先放弃” |
3.1 301/302 把 POST 变 GET 的实测¶
# 不带 -L 先看跳转
curl -sS -i --data-raw '{"skuId":1001}' \
-H 'Content-Type: application/json' \
http://api.example.com/api/v1/orders | head -n 5
# HTTP/1.1 301 Moved Permanently
# Location: https://api.example.com/api/v1/orders
# 跟随跳转,观察实际发出的方法
curl -sS -L --data-raw '{"skuId":1001}' -v \
http://api.example.com/api/v1/orders 2>&1 | grep -E '^> (POST|GET) '
# > POST /api/v1/orders HTTP/1.1
# > GET /api/v1/orders HTTP/1.1 ← 301 后方法被降级,body 丢失
curl 8.x 在 301/302/303 后默认把 POST 改成 GET;要复现“保持 POST”的客户端行为用 --post301 --post302。反过来,-X POST 会让重定向后的请求也被强制成 POST,这是 -X 的副作用,调试时不要用它来“指定方法”。
3.2 415 的真实响应体¶
Spring Boot 3(server.error.include-message=always):
{"timestamp":"2026-10-22T11:12:41.512+08:00","status":415,"error":"Unsupported Media Type",
"message":"Content-Type 'text/plain;charset=UTF-8' is not supported","path":"/api/v1/orders"}
触发方式(把 JSON 当纯文本发):
curl -sS -i https://api.example.com/api/v1/orders \
-H 'Content-Type: text/plain' \
--data-raw '{"skuId":1001,"count":2}'
注意 curl -d 默认就发 application/x-www-form-urlencoded,很多“我明明写了 JSON”其实根本没写 Content-Type。用 --data-raw 也一样,必须显式 -H。
3.3 422 的两种形态¶
FastAPI(Pydantic v2,0.100+)字段缺失:
Rails/DRF 风格则是把错误挂在字段上:
断言时不要只判断状态码,必须判断错误定位字段(loc 或字段名),否则“报错但报错字段不对”的 bug 会漏掉。
3.4 429 与限流信息¶
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1761100030
Content-Type: application/json
{"code":"RATE_LIMITED","message":"requests per minute exceeded"}
验证方法:在 60 秒窗口内连发 110 次,统计第几次开始 429。
for i in $(seq 1 110); do
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" \
https://api.example.com/api/v1/users)
echo "$i $code"
done | awk '$2==429{print "first 429 at:", $1; exit}'
判定标准:首次 429 的位置应等于配置阈值(允许 1~2 的误差,因为窗口是滑动的);且 Retry-After 结束后第一个请求必须恢复 200。
3.5 502/504 的网关响应¶
<html>
<head><title>504 Gateway Time-out</title></head>
<body>
<center><h1>504 Gateway Time-out</h1></center>
<hr><center>nginx/1.24.0</center>
</body>
</html>
看到这个页面说明请求已经到了 Nginx,但 upstream 没在 proxy_read_timeout(默认 60s)内返回。要在测试报告里区分:
| 现象 | 责任方 | 证据 |
|---|---|---|
| 504,上游日志有慢 SQL | 应用/数据库 | 应用 access log 有该请求,耗时接近超时值 |
| 502,上游日志无该请求 | 网关到上游的连接问题 | ss -tnp 看到 SYN_SENT,或 upstream 端口未监听 |
| 502,上游日志有请求但连接被重置 | 应用崩溃/主动关闭 | 应用侧有 Connection reset by peer 或 OOM 记录 |
四、Header 逐项¶
| Header | 关键点 | 测试动作 |
|---|---|---|
Content-Type |
决定服务端用哪个 MessageConverter;application/json 与 application/json;charset=UTF-8 不等价于“带 charset 就一定能解析” |
去掉、改错、加 charset、用 application/vnd.api+json 各发一次 |
Authorization |
Bearer 与 Basic 的编码方式完全不同;大小写敏感 |
改 scheme、去掉前缀、放过期 token |
Cookie |
多值用 ; 分隔;HttpOnly/Secure/SameSite 影响能否跨域带上 |
手工删单个 Cookie 验证会话依赖 |
X-Forwarded-For |
代理链追加,最左通常是客户端伪造值 | 伪造最左 IP 测限流与审计日志 |
ETag |
强校验 "abc",弱校验 W/"abc" |
用弱 ETag 发 If-None-Match 看是否 304 |
Transfer-Encoding |
chunked 时无 Content-Length |
用 --raw 手工发 chunked 测网关兼容 |
Accept-Encoding |
客户端声明可接受的压缩算法 | 只发 gzip,看服务端是否仍返回 br |
Connection |
keep-alive / close |
发 Connection: close 验证连接是否真的关闭 |
X-Forwarded-For 的经典漏洞:应用取 XFF.split(",")[0] 作为客户端 IP,攻击者每次换一个伪造值就能绕过按 IP 的限流。
for ip in 1.1.1.1 2.2.2.2 3.3.3.3; do
curl -s -o /dev/null -w "$ip -> %{http_code}\n" \
-H "X-Forwarded-For: $ip" \
-H "Authorization: Bearer $TOKEN" \
--data-raw '{"mobile":"13800000000"}' \
https://api.example.com/api/v1/sms/send
done
# 若三次都是 200,说明限流取的是可伪造的 XFF 最左值
正确做法是只信任自己代理写入的那一段(Nginx 用 $proxy_add_x_forwarded_for 追加,应用取“从右往左数第 n 个”),并且边界网关要清洗外部传入的 XFF。
五、缓存与 304 实测¶
# 第一次请求,拿 ETag
curl -sS -D - -o /dev/null https://static.example.com/app.js | grep -iE 'etag|last-modified|cache-control'
# ETag: "6512a4c8-1a0"
# Last-Modified: Tue, 20 Oct 2026 08:00:00 GMT
# Cache-Control: public, max-age=3600
# 第二次带条件请求
curl -sS -i -H 'If-None-Match: "6512a4c8-1a0"' https://static.example.com/app.js
# HTTP/1.1 304 Not Modified
# ETag: "6512a4c8-1a0"
# (空行后无 body)
判定标准:
- 304 的响应必须没有 body;用
curl -s ... -w '%{size_download}'应为 0。 - 304 必须带同一个 ETag;如果 ETag 变了却仍返回 304,说明缓存键设计有问题。
- 带
Cache-Control: no-cache时仍应走条件请求;带no-store时不应产生 304。 - 测试环境常见坑:Nginx 开了
gzip后 ETag 变成弱 ETag(W/"..."),拿强 ETag 去匹配会一直 200。
六、keep-alive 与连接复用¶
curl -sS -v https://api.example.com/health https://api.example.com/version \
2>&1 | grep -E 'Connected to|Re-using|left intact'
# * Connected to api.example.com (203.0.113.10) port 443
# * Re-using existing connection with host api.example.com
# * Connection #0 to host api.example.com left intact
如果第二次仍是 Connected to,说明服务端返回了 Connection: close,或中间设备强制短连接。
真实故障:服务端 keepalive_timeout 75s,客户端连接池空闲回收时间设成 90s,复用到已被服务端关闭的连接:
Python 侧表现为:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retry = Retry(total=2, backoff_factor=0.2,
status_forcelist=[502, 503, 504],
allowed_methods=frozenset(["GET", "PUT", "DELETE"]))
session.mount("https://", HTTPAdapter(max_retries=retry, pool_maxsize=20))
注意 allowed_methods 默认只含 GET/HEAD/PUT/DELETE/OPTIONS/TRACE,POST 不重试是对的——非幂等请求盲目重试会造重复单。另外 Retry 不会重试“连接被重置”之外的读超时,读超时要靠业务层幂等键兜底。
七、curl 全参数实战¶
| 参数 | 用途 | 真实注意点 |
|---|---|---|
-i |
输出响应头 | 与 -I(HEAD)不同,-i 仍发原方法 |
-v |
打印握手与头 | 看 TLS 版本、SNI、复用了哪条连接 |
--data-raw |
原样发送 body | 不把 @ 当文件,适合粘贴浏览器复制的请求 |
-G |
把 -d 内容拼到 query |
配合 --data-urlencode 才安全 |
--compressed |
声明并自动解压 | 不加会拿到 gzip 二进制 |
-x |
指定代理 | 抓包时 -x http://127.0.0.1:8888 |
--cacert |
指定 CA | 自签环境用,别用 -k 掩盖问题 |
--resolve |
强制域名到 IP | 测未切流量的新集群 |
-w |
输出指标 | %{http_code} %{time_total} %{size_download} |
--max-time |
总超时 | 与 --connect-timeout 分开设 |
-b/-c |
读写 Cookie jar | 复现登录态最省事的方式 |
-OJ |
按服务端文件名保存 | 中文文件名会乱码,见第九节 |
# 1. 走代理抓包,忽略代理对自签证书的校验只用于本地
curl -sS -x http://127.0.0.1:8888 --cacert ./fiddler-ca.pem \
https://api.example.com/api/v1/orders -o /dev/null -w '%{http_code}\n'
# 2. 把域名指到测试集群
curl -sS --resolve api.example.com:443:10.20.30.40 \
https://api.example.com/health
# 3. GET 带中文与加号,必须 --data-urlencode
curl -sS -G https://api.example.com/api/v1/search \
--data-urlencode 'q=C++ 入门' \
--data-urlencode 'page=1' | jq '.data.total'
# 4. 保存并复用 Cookie
curl -sS -c cookies.txt -X POST https://api.example.com/api/v1/login \
-H 'Content-Type: application/json' \
--data-raw '{"username":"qa01","password":"Passw0rd!"}'
curl -sS -b cookies.txt https://api.example.com/api/v1/me | jq '.data.id'
# 5. 记录耗时分解
curl -sS -o /dev/null -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://api.example.com/api/v1/orders
八、抓包与重放¶
Chrome DevTools 对请求右键 Copy → Copy as cURL (bash),粘贴出来长这样:
curl 'https://api.example.com/api/v1/orders' \
-H 'authority: api.example.com' \
-H 'accept: application/json, text/plain, */*' \
-H 'content-type: application/json' \
-H 'cookie: SESSION=8f2a...; lang=zh-CN' \
--data-raw '{"skuId":1001,"count":2}' \
--compressed
重放时三个高频失败:
- Cookie 过期:返回 302 到
/login或 401。先单独请求/api/v1/me确认会话是否有效。 - 签名/时间戳过期:返回
{"code":"SIGN_EXPIRED"}。这类请求不能直接重放,必须用脚本重新签名。 - 一次性 nonce 已被消费:返回 409 或
REPLAY_DETECTED。这不是 bug,是防重放生效。
HAR 重放可以用 har2case/Postman 导入,也可以直接用 Python 把 HAR 里的条目转成请求:
import json, requests
har = json.load(open("capture.har", encoding="utf-8"))
for e in har["log"]["entries"][:5]:
req = e["request"]
headers = {h["name"]: h["value"] for h in req["headers"]
if h["name"].lower() not in ("host", "content-length")}
r = requests.request(req["method"], req["url"], headers=headers,
data=req.get("postData", {}).get("text"), timeout=10)
print(req["method"], req["url"], r.status_code)
九、Postman 环境变量与脚本¶
变量优先级(从高到低):脚本里 pm.variables.set 的局部值 → Collection Runner 的 data 文件 → Environment → Collection → Global。环境切换靠右上角下拉,CI 里用 newman run collection.json -e env.json。
带签名的 Pre-request Script:
const ts = Date.now().toString();
const nonce = pm.variables.replaceIn('{{$guid}}');
const appKey = pm.environment.get('appKey');
const secret = pm.environment.get('appSecret');
pm.environment.set('ts', ts);
pm.environment.set('nonce', nonce);
const canonical = [pm.request.method.toUpperCase(), pm.request.url.getPath(),
ts, nonce].join('\n');
pm.environment.set('sign',
CryptoJS.HmacSHA256(canonical, secret).toString(CryptoJS.enc.Hex));
Tests 脚本(断言而不是“看一眼”):
pm.test('201 Created', () => pm.response.to.have.status(201));
pm.test('Content-Type 是 JSON', () =>
pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json'));
pm.test('orderId 为正整数', () => {
const body = pm.response.json();
pm.expect(body.code).to.eql(0);
pm.expect(body.data.orderId).to.be.a('number').and.above(0);
});
pm.test('响应时间 < 800ms', () =>
pm.expect(pm.response.responseTime).to.be.below(800));
内置动态变量:{{$timestamp}}(秒)、{{$isoTimestamp}}、{{$guid}}、{{$randomInt}}、{{$randomFullName}}。它们每次引用都会重新求值,所以同一个请求里取两次 timestamp 可能差 1 秒,签名场景要先用脚本落到环境变量再引用。
十、签名与时间戳¶
服务端校验的典型要素:method + path + bodyHash + timestamp + nonce。Python 实现:
import hashlib, hmac, time, uuid, requests
def sign(secret: str, method: str, path: str, body: bytes, ts: str, nonce: str) -> str:
canonical = "\n".join([method.upper(), path,
hashlib.sha256(body).hexdigest(), ts, nonce])
return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
body = b'{"skuId":1001,"count":2}'
ts, nonce = str(int(time.time())), uuid.uuid4().hex
headers = {
"Content-Type": "application/json",
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Sign": sign("s3cr3t", "POST", "/api/v1/orders", body, ts, nonce),
}
r = requests.post("https://api.example.com/api/v1/orders",
headers=headers, data=body, timeout=5)
print(r.status_code, r.text)
必测的四条负向用例:
| 篡改点 | 预期 | 判定依据 |
|---|---|---|
| 时间戳改为 10 分钟前 | 401 | 错误码为时间偏移类,如 SIGN_EXPIRED |
| 同一个 nonce 发两次 | 第二次 409/401 | 不能两次都成功 |
| body 改一个字节、签名不变 | 401 | 说明 bodyHash 参与了签名 |
path 后加 ?a=1、签名不变 |
401 | 说明 query 参与签名,或明确约定不参与 |
十一、五个反复踩的坑¶
11.1 Content-Type 缺失导致 415¶
curl -d 默认发 application/x-www-form-urlencoded,Spring 的 @RequestBody 收到表单会直接 415。前端用 fetch 忘了写 header、Postman 把 Body 选成 form-data,都是同一个结果。排查顺序:抓包看请求头 → 看服务端 415 的 message 里的实际 Content-Type → 再改。
11.2 GET 带 body¶
规范没有禁止,但中间件行为不一致。实测差异:
| 组件 | 行为 |
|---|---|
| Nginx 1.24 | 默认转发 body,但 proxy_pass 后是否保留取决于版本与配置 |
| Tomcat 10 | 允许读取 GET body |
| Node.js + body-parser | 默认不解析 GET,req.body 为 undefined |
| 部分云 API 网关 | 直接丢弃 body 或返回 400 |
复现命令:
curl -sS -i -X GET https://api.example.com/api/v1/orders/query \
-H 'Content-Type: application/json' \
--data-raw '{"orderId":9001}'
如果必须传复杂查询条件,改成 POST + /query 子路径,别赌网关行为。
11.3 URL 编码与双重编码¶
query 里空格应编码为 %20(+ 只在 application/x-www-form-urlencoded 里代表空格),+ 本身要编码成 %2B。前端先 encodeURIComponent('C++') 得到 C%2B%2B,网关再编码一次变成 C%252B%252B,服务端解一次得到 C%2B%2B 并把字面量当成关键词:
# 正确
curl -sS -G https://api.example.com/api/v1/search --data-urlencode 'q=C++'
# 错误示范:双重编码
curl -sS 'https://api.example.com/api/v1/search?q=C%252B%252B'
# 服务端日志打印 q=C%2B%2B,搜索结果为 0
判定方法:在服务端日志里打印解码后的参数值,和前端原值逐字符比对,不要只看“有没有返回 200”。
11.4 gzip 乱码¶
curl -sS -H 'Accept-Encoding: gzip' https://api.example.com/api/v1/users -o out.bin
file out.bin
# out.bin: gzip compressed data, from Unix
curl -sS --compressed https://api.example.com/api/v1/users | jq '.code'
Python 里 requests 会自动解压,但如果你用 http.client 或自己拼 socket,必须判断 Content-Encoding 再 gzip.decompress:
import gzip, requests
r = requests.get(url, headers={"Accept-Encoding": "gzip"}, timeout=5)
print(r.headers.get("Content-Encoding")) # gzip
print(r.text[:60]) # 已解压,可直接读
另一个坑:先用 gzip 解压一次、又对 resp.text 做 json.loads,如果响应被 br 压缩而客户端不支持,会得到 UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b。看到 0x8b 就该想到 gzip 魔数 1f 8b。
11.5 中文文件名¶
服务端只写 filename="测试报告.xlsx",HTTP 头按 ISO-8859-1 解释,客户端存成 ???.xlsx。正确写法是同时给 ASCII 回退和 RFC 5987 的 filename*:
Content-Disposition: attachment; filename="report.xlsx"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.xlsx
Java 里 URLEncoder.encode(name, "UTF-8") 会把空格变成 +,放进 filename* 后仍然错误,应使用 %20。验证:
十二、可直接复用的断言模板¶
import requests
def test_create_order(base_url, token):
body = {"skuId": 1001, "count": 2}
r = requests.post(f"{base_url}/api/v1/orders",
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"},
json=body, timeout=5)
assert r.status_code == 201, r.text
assert r.headers["Content-Type"].startswith("application/json")
assert r.headers.get("Location", "").startswith("/api/v1/orders/")
data = r.json()["data"]
assert data["amount"] == "100.00" # 金额用字符串比
assert data["status"] == "PENDING"
assert r.elapsed.total_seconds() < 0.8
命令行侧的等价检查:
curl -sS -D /tmp/h.txt https://api.example.com/api/v1/orders/9001 \
-H "Authorization: Bearer $TOKEN" -o /tmp/b.json
grep -i '^content-type: application/json' /tmp/h.txt
jq -e '.code == 0 and .data.orderId == 9001 and (.data.amount | test("^[0-9]+\\.[0-9]{2}$"))' /tmp/b.json
十三、检查清单¶
| 检查项 | 判定标准 | 常见错误结论 |
|---|---|---|
| Content-Type | 与 body 实际格式一致,含 charset | “我传了 JSON”其实发的是表单 |
| 方法语义 | GET/PUT/DELETE 幂等,POST 有幂等键 | 用 POST 做查询,无法缓存也无法安全重试 |
| 状态码 | 与语义表一致,错误体可定位字段 | 所有错误都返回 200 + code |
| 认证 | 缺失/过期/篡改分别是 401,越权是 403 | 401 与 403 混用 |
| 缓存 | 304 无 body,ETag 一致 | 只测首次 200 |
| 编码 | 中文、+、%、emoji 各一次 |
只测英文 |
| 压缩 | gzip/br 都能正确解 | 用 --compressed 掩盖服务端错误声明 |
| 超时与重试 | 读超时、连接重置都有明确错误码 | 前端无限重试非幂等请求 |