适用场景:地址跳转成功,业务请求却变了
接口从 /orders 迁移到 /orders/,或者网关增加域名规范化规则后,调用方开始收到 405、参数缺失,甚至收到 200 的 HTML 页面。调用日志只保留最终响应,看起来像后端突然不认识请求体。
这类问题先查重定向链:客户端发出的第一跳是 POST,不代表最终到达业务服务的仍然是 POST。下面用订单接口构造可重复场景,不涉及真实订单,也不访问公网。
先分清状态码允许客户端做什么
| 状态码 | 含义 | POST 自动跟随后要注意什么 |
|---|---|---|
| 301 | 永久迁移 | 协议允许客户端改成 GET,不能依靠它保留 POST |
| 302 | 临时迁移 | 协议同样允许 POST 改成 GET |
| 303 | 转向另一个结果资源 | 通常以 GET 获取结果,适合提交后查看结果页 |
| 307 | 临时迁移 | 自动跟随时必须保留请求方法 |
| 308 | 永久迁移 | 自动跟随时必须保留请求方法 |
这是协议边界,不代表所有客户端对 301、302 都采用同一种实现。依据是 RFC 9110 第 15.4 节。对需要继续发送原请求内容的接口迁移,应使用 307 或 308,并验证请求体确实可重放。不要把“保留方法”理解成“客户端一定能重新读取已消耗的上传流”。
HTTPX 默认不自动跟随重定向;开启 follow_redirects=True 后,response.history 保存已跟随的响应。只检查最终状态码会漏掉中间的 302。参见 HTTPX 重定向文档。
用一个离线实验看见方法和请求体变化
环境为 Python 3.14.6、HTTPX 0.28.1。将以下完整代码保存为 test_redirects.py,在已安装 HTTPX 的环境中执行 python -m unittest -v test_redirects。
代码通过官方支持的 MockTransport 模拟两跳请求;api.example.test 不会被解析,也不会建立网络连接。所有请求内容都是固定测试数据,不能把这里采集请求体的写法照搬到生产日志。
import unittest
import httpx
PAYLOAD = b'{"product_id":"demo","quantity":1}'
Call = tuple[str, str, bytes, str | None]
def run_case(
status_code: int,
*,
follow_redirects: bool = True,
path: str = "/orders",
has_location: bool = True,
) -> tuple[httpx.Response, list[Call]]:
"""用固定字节请求体观察两跳行为,不连接外部服务。"""
calls: list[Call] = []
def handle(request: httpx.Request) -> httpx.Response:
calls.append(
(
request.method,
request.url.path,
request.content,
request.headers.get("content-type"),
)
)
if request.url.path == "/orders":
headers = {"Location": "/orders/"} if has_location else {}
return httpx.Response(status_code, headers=headers)
if request.url.path == "/orders/":
if request.method != "POST":
return httpx.Response(405, headers={"Allow": "POST"})
return httpx.Response(201, json={"order_id": "demo-order"})
return httpx.Response(404)
with httpx.Client(
transport=httpx.MockTransport(handle),
base_url="https://api.example.test",
follow_redirects=follow_redirects,
timeout=5.0,
) as client:
response = client.post(
path,
content=PAYLOAD,
headers={"Content-Type": "application/json"},
)
return response, calls
class RedirectTests(unittest.TestCase):
"""固定客户端版本下的重定向回归合同。"""
def check_redirect(self, status_code: int, *, preserves_post: bool) -> None:
response, calls = run_case(status_code)
self.assertEqual([item.status_code for item in response.history], [status_code])
self.assertEqual(calls[0], ("POST", "/orders", PAYLOAD, "application/json"))
expected = (
("POST", "/orders/", PAYLOAD, "application/json")
if preserves_post
else ("GET", "/orders/", b"", "application/json")
)
self.assertEqual(calls[1:], [expected])
self.assertEqual(response.status_code, 201 if preserves_post else 405)
def test_301_changes_post_to_get(self) -> None:
self.check_redirect(301, preserves_post=False)
def test_302_changes_post_to_get(self) -> None:
self.check_redirect(302, preserves_post=False)
def test_303_fetches_result_with_get(self) -> None:
self.check_redirect(303, preserves_post=False)
def test_307_preserves_post_and_body(self) -> None:
self.check_redirect(307, preserves_post=True)
def test_308_preserves_post_and_body(self) -> None:
self.check_redirect(308, preserves_post=True)
def test_disabled_follow_exposes_first_hop(self) -> None:
response, calls = run_case(302, follow_redirects=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["location"], "/orders/")
self.assertEqual(response.history, [])
self.assertEqual(len(calls), 1)
with self.assertRaises(httpx.HTTPStatusError):
response.raise_for_status()
def test_direct_canonical_url_needs_no_redirect(self) -> None:
response, calls = run_case(302, path="/orders/")
self.assertEqual(response.status_code, 201)
self.assertEqual(response.history, [])
self.assertEqual(calls, [("POST", "/orders/", PAYLOAD, "application/json")])
def test_missing_location_does_not_make_second_request(self) -> None:
response, calls = run_case(302, has_location=False)
self.assertEqual(response.status_code, 302)
self.assertEqual(len(calls), 1)
def test_redirect_loop_has_a_limit(self) -> None:
def loop(request: httpx.Request) -> httpx.Response:
return httpx.Response(307, headers={"Location": "/orders"})
with httpx.Client(
transport=httpx.MockTransport(loop),
max_redirects=2,
follow_redirects=True,
timeout=5.0,
) as client:
with self.assertRaises(httpx.TooManyRedirects):
client.post("https://api.example.test/orders", content=PAYLOAD)
if __name__ == "__main__":
unittest.main(verbosity=2)
在上述版本中,301、302、303 的第二跳为 GET,正文为空,但手动设置的 Content-Type: application/json 仍保留,模拟接口返回 405;307、308 的第二跳保留 POST、JSON 字节和内容类型,返回 201。直接请求 /orders/ 则只有一次调用。因此,看到 JSON 内容类型不能证明请求携带了 JSON 正文。
calls 证明服务端最终收到了什么;history 证明中间经过哪些状态码;response.request.method 表示最终请求的方法。三者比单独查看最终 URL 更能解释故障。
生产排查:从第一跳开始取证
- 先在测试环境使用脱敏数据关闭自动跟随,检查第一跳状态码、路径以及
Location的目标源站和路径。不要输出完整查询串,跳转地址可能携带签名参数。 - 对照网关与应用的请求日志,逐跳核对
request_method、不含查询串的路由、status_code、request_id和upstream_status。记录请求体长度即可,不记录正文、Cookie 或鉴权头。 - 检查重定向来自哪层:HTTP 到 HTTPS、旧域名到新域名、尾斜杠、登录中间件、应用路由,都可能改写入口。301 或 302 只能证明发生跳转,不能直接证明是哪一层配置有错。
- 在明确受控的同源测试链上开启跟随,核对最终方法、响应内容类型和业务结果。若末端是登录页,200 仅表示页面获取成功,应继续检查会话与鉴权链路。
不能从“请求体为空”直接推断代理截断了数据。如果第二跳已经变成 GET,应先修复重定向合同,再查缓冲区和大小限制。
修复选择:优先让调用方直达规范地址
第一选择是修改调用方配置。 将实际提交地址改为规范 HTTPS 地址及准确路径,例如 /orders/。这样同时消除跳转、额外往返,以及不可重放请求体的问题。API 文档、SDK 默认地址与健康检查应同步更新。
必须保留旧入口时,明确其行为。 如果旧入口尚未执行业务,只负责把原请求交给新入口,可先使用 307 做受控迁移,并用真实 SDK 测试方法、正文、鉴权和目标地址;永久迁移稳定后再评估 308。301、308 可能被缓存,回滚时不能假定撤销源站规则就能立刻召回客户端,必须考虑实际缓存策略和旧入口兼容期。
303 有自己的适用场景。 如果 POST 已经创建订单,服务端通过 303 引导浏览器 GET 订单结果页,这是正常的提交后跳转。此时改成 307 可能把创建请求再次提交到结果地址。必须先确认第一跳有没有产生业务副作用。
对固定地址的机器接口,可保持 follow_redirects=False,把意外 3xx 当作接口合同变化并报警。本文测试验证了 HTTPX 0.28.1 的 raise_for_status() 会对未跟随的 302 抛出 HTTPStatusError;不要将其泛化到其他客户端。
上线验收与回滚边界
- 分别测试旧入口、规范入口、未登录、目标不可达、缺少 Location 和循环跳转,既检查 HTTP 状态,也检查实际业务结果。
- 涉及上传流时,单独验证 SDK 是否支持重新读取数据,限制体积;本文固定字节示例不能证明文件流可重放。
- 307、308 只解决方法保留,不保证业务幂等。发生超时后,应按业务幂等键和结果查询机制确认是否已成功,不能盲目重复创建订单。
- 对跨源或 HTTPS 降级跳转单独制定允许策略,核对目标源站及凭据转发行为;不能因为状态码是 307 就信任目标。
- 回滚时保留可直接处理原请求的旧路由,监测旧入口流量与 3xx 比例。不要只把 307 改回 302,这会重新引入 POST 方法变化。
验证范围与结论
本文在 Python 3.14.6、HTTPX 0.28.1 下执行完整九项 unittest,验证五种状态码的方法与正文、关闭跟随、直接访问规范地址、缺少 Location 和跳转循环。实验使用 MockTransport,未验证真实反向代理、浏览器、其他 SDK、认证头跨源策略、TLS、缓存或流式上传;这些需要在实际部署链路上补做验收。
遇到 POST 请求体“丢失”,先还原每一跳的方法,再讨论代理是否吞掉正文。规范地址直达、迁移状态码选择和回归测试一起落实,才能避免下一次域名或路由调整再次触发同样的问题。
Discussion
评论