接口最初发的是 POST,最终响应却像普通列表页,请求体里的筛选条件也没了。此时先检查重定向链:程序最终拿到的 200,不一定对应最初的那个请求。
本文用一个只监听 127.0.0.1 的小服务,把服务端实际收到的方法和正文记录下来。没有访问真实业务站点,也没有测试绕过任何访问限制。
一、先限定实验结论
验证日期为 2026-09-12,环境是 Python 3.13.13、Requests 2.33.1。对同源跳转和可重复读取的 bytes 正文,本轮结果如下:
| 第一次响应 | 起始请求 | 跳转后的请求 | 目标端收到的正文 |
|---|---|---|---|
| 301 | POST | GET | 空 |
| 302 | POST | GET | 空 |
| 303 | POST | GET | 空 |
| 307 | POST | POST | 原来的 14 字节 |
| 308 | POST | POST | 原来的 14 字节 |
这张表描述本轮客户端与输入的实测,不应外推成“所有 HTTP 方法遇到这些状态码都这么处理”。
协议允许 301、302 的后续请求将 POST 改为 GET;303 指向另一个可检索资源;307、308 在自动跳转时要求保留请求方法。可对照 RFC 9110 的重定向章节。本机 Requests 的 rebuild_method 实现也已核对。
二、完整本地实验
保存为 redirect_demo.py,在已有 Requests 的 Python 环境执行 python redirect_demo.py。脚本会启动临时服务,结束后关闭;固定的 sku 是实验数据。只为本地验证设置 trust_env=False,不把这个设置当作真实网络故障的通用修复。
"""Only connects to a temporary HTTP server on 127.0.0.1."""
import json
import platform
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import requests
SEEN = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def handle_request(self):
body = self.rfile.read(int(self.headers.get('Content-Length', '0')))
SEEN.append((self.path, self.command, body))
if self.path.startswith('/r/') or self.path == '/loop':
status = 302 if self.path == '/loop' else int(self.path.rsplit('/', 1)[1])
self.send_response(status)
self.send_header('Location', '/loop' if self.path == '/loop' else '/echo')
self.send_header('Content-Length', '0')
self.end_headers()
return
data = json.dumps({'method': self.command, 'body': body.decode('utf-8')}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = handle_request
do_POST = handle_request
def main():
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
base = f'http://127.0.0.1:{server.server_port}'
payload = b'{"sku":"A-01"}'
print(f'Python {platform.python_version()} / Requests {requests.__version__}')
try:
with requests.Session() as client:
client.trust_env = False # This local experiment needs no proxy or netrc.
for status in (301, 302, 303, 307, 308):
SEEN.clear()
r = client.post(base + f'/r/{status}', data=payload,
headers={'Content-Type': 'application/json'}, timeout=(2, 2))
expected_method = 'POST' if status in (307, 308) else 'GET'
expected_body = payload if status in (307, 308) else b''
assert [h.status_code for h in r.history] == [status]
assert r.history[0].request.method == 'POST'
assert r.request.method == expected_method
assert SEEN == [(f'/r/{status}', 'POST', payload),
('/echo', expected_method, expected_body)]
assert r.json() == {'method': expected_method, 'body': expected_body.decode()}
print(f'PASS {status}: POST -> {expected_method}, final_body_bytes={len(expected_body)}')
SEEN.clear()
r = client.post(base + '/r/302', data=payload,
allow_redirects=False, timeout=(2, 2))
assert r.status_code == 302 and not r.history
assert r.headers['Location'] == '/echo'
assert SEEN == [('/r/302', 'POST', payload)]
print('PASS redirects disabled: one request, original 302 visible')
client.max_redirects = 2
try:
client.get(base + '/loop', timeout=(2, 2))
except requests.TooManyRedirects:
print('PASS redirect loop: stopped by max_redirects')
else:
raise AssertionError('redirect loop did not stop')
finally:
server.shutdown()
server.server_close()
worker.join()
print('7 checks passed')
if __name__ == '__main__':
main()
本轮实际输出:
Python 3.13.13 / Requests 2.33.1
PASS 301: POST -> GET, final_body_bytes=0
PASS 302: POST -> GET, final_body_bytes=0
PASS 303: POST -> GET, final_body_bytes=0
PASS 307: POST -> POST, final_body_bytes=14
PASS 308: POST -> POST, final_body_bytes=14
PASS redirects disabled: one request, original 302 visible
PASS redirect loop: stopped by max_redirects
7 checks passed
前五项同时检查了客户端 history、最终 PreparedRequest 和服务端收到的原始 body。这样可以区分“日志里以为发了什么”和“目标实际收到什么”。第六项关闭自动跳转,断言服务端只收到一次请求;第七项验证循环跳转会被设定的 max_redirects 终止。
三、排障时看每一跳,不只看最终状态码
建议保存这几类经过脱敏的信息:每跳状态码、请求方法、目标主机与路径、Location,以及请求体长度。必要时在授权测试环境里比较正文摘要。不要把完整 Cookie、Authorization 或私密查询参数写进普通日志。
Requests 的 response.history 包含先前响应;当前 response.request 对应最终请求。也可以暂时传 allow_redirects=False,观察第一次响应的 Location。相关接口见 Requests 重定向与历史文档。
若原地址跳到登录页,最终的 200 只说明登录页被正常返回。若 302 之后变成 GET,先确认这是业务设计还是客户端和服务端约定不一致。不要靠重复粘贴请求头掩盖请求路径变化。
四、保留 POST 并不等于可以随意重发
307、308 保留方法,有助于解释请求体为何还在。但如果 POST 会创建任务、发送通知或写入记录,后续提交仍然可能产生真实副作用。关闭自动跳转也不会撤销已经发出的第一跳。
遇到写操作,先核对服务端的幂等约定和操作状态。客户端没有收到结果,并不能证明服务端没有执行。若要手动处理 Location,应按实际接口约定校验目标协议、主机与路径,而不是无条件把正文和凭证转发过去。
五、这次没有验证什么
本实验没有覆盖跨域凭证处理、HTTPS、代理、浏览器 Fetch、文件上传或不可回卷的数据流。不能据此声称所有客户端都会重放任意请求体。timeout=(2, 2) 也不是整个重定向链的总耗时预算;生产程序还要限制整体时间和跳转次数。
本文由 AI 辅助起草与校核;上面列出的 7 项检查已在所述本地环境运行。协议说明和固定实验结果分开记录,没有将本地结果冒充线上故障结论。

362

被折叠的 条评论
为什么被折叠?



