适用场景:地址跳转成功,业务请求却变了

接口从 /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 更能解释故障。

生产排查:从第一跳开始取证

  1. 先在测试环境使用脱敏数据关闭自动跟随,检查第一跳状态码、路径以及 Location 的目标源站和路径。不要输出完整查询串,跳转地址可能携带签名参数。
  2. 对照网关与应用的请求日志,逐跳核对 request_method、不含查询串的路由、status_code、request_id 和 upstream_status。记录请求体长度即可,不记录正文、Cookie 或鉴权头。
  3. 检查重定向来自哪层:HTTP 到 HTTPS、旧域名到新域名、尾斜杠、登录中间件、应用路由,都可能改写入口。301 或 302 只能证明发生跳转,不能直接证明是哪一层配置有错。
  4. 在明确受控的同源测试链上开启跟随,核对最终方法、响应内容类型和业务结果。若末端是登录页,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 请求体“丢失”,先还原每一跳的方法,再讨论代理是否吞掉正文。规范地址直达、迁移状态码选择和回归测试一起落实,才能避免下一次域名或路由调整再次触发同样的问题。