Python 爬虫 POST 变 GET?用 7 项本地验证排查 301、302、303、307、308

接口最初发的是 POST,最终响应却像普通列表页,请求体里的筛选条件也没了。此时先检查重定向链:程序最终拿到的 200,不一定对应最初的那个请求。

本文用一个只监听 127.0.0.1 的小服务,把服务端实际收到的方法和正文记录下来。没有访问真实业务站点,也没有测试绕过任何访问限制。

一、先限定实验结论

验证日期为 2026-09-12,环境是 Python 3.13.13、Requests 2.33.1。对同源跳转和可重复读取的 bytes 正文,本轮结果如下:

第一次响应起始请求跳转后的请求目标端收到的正文
301POSTGET
302POSTGET
303POSTGET
307POSTPOST原来的 14 字节
308POSTPOST原来的 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 项检查已在所述本地环境运行。协议说明和固定实验结果分开记录,没有将本地结果冒充线上故障结论。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值