跳转至

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+)字段缺失:

{"detail":[{"type":"missing","loc":["body","skuId"],"msg":"Field required","input":{"count":2}}]}

Rails/DRF 风格则是把错误挂在字段上:

{"errors":{"skuId":["can't be blank"],"count":["must be greater than 0"]}}

断言时不要只判断状态码,必须判断错误定位字段(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 关键点 测试动作
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,复用到已被服务端关闭的连接:

curl: (56) Recv failure: Connection reset by peer

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

重放时三个高频失败:

  1. Cookie 过期:返回 302 到 /login 或 401。先单独请求 /api/v1/me 确认会话是否有效。
  2. 签名/时间戳过期:返回 {"code":"SIGN_EXPIRED"}。这类请求不能直接重放,必须用脚本重新签名。
  3. 一次性 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。验证:

curl -sS -OJ https://api.example.com/api/v1/report/export
ls -1 *.xlsx

十二、可直接复用的断言模板

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 掩盖服务端错误声明
超时与重试 读超时、连接重置都有明确错误码 前端无限重试非幂等请求

相关阅读