☰
Python HTTP重定向全链路:状态码、requests与httpx实战
2026/10/26 22:26:04 网站建设 项目流程

前段时间有人丢给我一段 Python 脚本,说请求某个接口拿到的永远是 200,但响应体里是一段莫名其妙的 HTML,跟文档里写的 JSON 字段完全对不上。我让他把response.url打印出来,一行日志就破案了——请求打出去的是 A 地址,最终落地在 B 地址,中间发生了三次 HTTP 重定向,而他自己压根没意识到这回事。这类问题在对接第三方接口、抓取页面、做登录态联调的时候几乎天天撞上,尤其是在 Python 里,不同 HTTP 库对重定向的默认态度完全不一样,你不主动管它,它就替你管,而且管得未必合你心意。

这篇东西聊的就是 Python 里处理 HTTP 重定向的完整链路:状态码到底怎么选、主流库背后干了什么、什么时候该让它自动跳、什么时候必须自己接管、服务端怎么正确地发出跳转、以及跳转链条断掉的时候从哪儿开始查。内容偏向实战,代码都能直接抄下来改改就用。不管你是刚写完第一个requests.get的新手,还是已经在维护一堆接口客户端的熟手,下面这些坑大概率有你踩过或者即将踩到的。

1. 重定向的语义边界与状态码选择

1.1 从一次请求链路看清跳转的本质

HTTP 重定向这件事,表面上看就是“服务器说去别处”,实际机制要精确一点:客户端发出请求,服务端返回一个 3xx 状态码,同时在响应头里塞一个Location字段,客户端读到这个字段后,再发起一次全新的请求到新地址。注意关键词是“再发起一次”,它不是一个请求被转发,而是两次独立的往返。中间那台服务器的工作到此结束,后面发生什么它并不知道。

这个特性决定了一堆实际问题。第一,跳转链上的每一跳都要重新建立请求上下文,Cookie、认证头、请求体这些状态能不能延续,取决于客户端实现,不是协议自动保证的。第二,跳转是有成本的,一次跳转就多一个 RTT,五次跳转在弱网下能让你多等一秒多。第三,Location里的地址可以是绝对 URL,也可以是相对路径,甚至是协议相对写法//host/path,客户端得自己把它拼成合法地址,拼错了就跳到沟里去。

我在排查时最常用的一个动作,就是把最终 URL 和跳转历史同时打出来:

import requests resp = requests.get("http://example.com/some-endpoint", timeout=10) print("最终地址:", resp.url) print("状态码:", resp.status_code) for i, r in enumerate(resp.history, 1): print(f"第{i}跳: {r.status_code} -> {r.headers.get('Location')}")

resp.history是个元组,按时间顺序存着中间所有 3xx 响应,最后一个才是最终响应。很多人只知道resp.status_code,一看到 200 就以为万事大吉,实际上这个 200 可能来自完全不相干的页面。养成打印resp.url的习惯,能省掉大量“为什么字段对不上”的困惑。

1.2 301、302、303、307、308 到底差在哪

这几个状态码是重定向里最容易混的部分,尤其是 301 和 302 的历史包袱。按 RFC 的语义,它们的区别集中在两个维度:是否永久、是否保持原请求方法。

状态码语义方法是否保持默认可缓存典型用途
301永久移动历史上常被改成 GET是,浏览器会长期记住域名迁移、URL 永久重构
302临时找到历史上常被改成 GET否(但浏览器实现有差异)登录后临时跳转
303参见其他地址强制改为 GET否POST 之后的页面跳转
307临时重定向严格保持原方法否临时维护、需要保留 POST 体
308永久重定向严格保持原方法是永久迁移但必须保留方法

301 和 302 在早期实现里有个历史遗留问题:很多客户端不管原请求是 POST 还是 PUT,收到 301/302 后统统改成 GET 去请求新地址,而且把请求体丢掉。这个行为没有被规范强制,但被广泛实现,所以后来才有了 303、307、308 来明确意图。303 的语义就是“你要找的东西在那边,用 GET 去拿”,非常适合表单提交后跳转到结果页。307 和 308 则是“原样重发”,方法、请求体都不变。

实操中我的建议很简单:凡是不确定的情况,优先用 302 或 303;确定是永久迁移且不需要保留方法,才用 301;需要保留 POST 体的临时跳转,用 307。308 要慎用,它的“永久”加“保留方法”组合一旦发出去,客户端缓存下来了,后续想改就得跟缓存搏斗。

注意:301 被浏览器缓存的时长是没有上限的,而且不受Cache-Control约束。一次错误的 301 可能让用户在很长一段时间里都访问不到正确地址,回滚服务端配置根本没用,因为请求压根没到服务端。

1.3 Python 生态里各家的默认姿势完全不同

这是最容易翻车的地方。同样是发一个 GET,换一个库,重定向行为可能完全相反:

库 / 模块默认是否跟随最大跳数关闭方式
requests跟随30allow_redirects=False
httpx不跟随20follow_redirects=True打开
urllib.request跟随10自定义HTTPRedirectHandler
aiohttp跟随10allow_redirects=False
urllib3跟随3(Retry 控制)redirect=False

httpx的默认行为是最多人搞错的一个。它的设计哲学偏向“显式优于隐式”,所以默认不跟随重定向,你请求一个会 302 的地址,拿到的就是那个 302 响应本身。有人从requests迁移到httpx,代码改完发现接口全挂,查了半天才发现是这个默认值在作怪。反过来,requests默认跟随 30 跳,这个数字足够大,以至于遇到循环重定向时你要等它跳满 30 次才报错,慢得让人怀疑人生。

在requests里限制跳数没有直接的参数,但可以挂一个自定义的HTTPAdapter,或者更干脆——关掉自动跳转自己走,这个后面细说。

2. requests 的重定向机制逐层拆解

2.1 allow_redirects 到底控制了什么

allow_redirects这个名字有点误导,它控制的不是“允不允许”,而是“这次调用要不要自动把跳转走完”。设为False时,requests不会跟随,直接把第一个 3xx 响应返回给你,resp.status_code就是 301 或 302,resp.headers['Location']里是目标地址,resp.history为空元组。

有一点容易忽略:HEAD 请求的默认值是False。也就是说你发requests.head(url),拿到 301 是正常的,别以为是服务端出了问题。这个设计有它的道理——HEAD 只关心头部信息,跟随重定向反而多花时间,但知道的人不多,经常有人在群里问“为什么 get 是 200,head 是 301”。

底层实现上,requests走的是Session.resolve_redirects这个生成器,它会在拿到响应后判断状态码是否在(301, 302, 303, 307, 308)里,再看Location是否为空,然后构造下一个PreparedRequest。Session.send里有个循环不断消费这个生成器,直到生成器耗尽或者跳数超限。理解了这一层,你就知道为什么resp.history能拿到完整的中间响应——循环过程中每一个响应都被收集起来了。

2.2 方法改写与请求体丢失的真实规则

很多人以为“301/302 会把 POST 改成 GET”,实际情况要精细一点。requests的判定逻辑大致是:状态码为 301、302、303 且原方法是 POST 时,把方法改成 GET,同时把请求体清空。注意这里只判断 POST,PUT、PATCH、DELETE 不在改写范围内,它们会保持原方法继续跳。

这个差异会导致很诡异的现象。同样一个接口,你用 POST 调用,跳转后变成 GET,参数全丢;你用 PUT 调用,跳转后还是 PUT,但请求体在跳转时被重新发送了,如果服务端没做幂等处理,可能产生重复写入。我遇到过一家服务商的重定向实现,把 307 当成 302 用,结果我们的 PUT 请求在跳转后对同一资源写了两次,排查了半天才定位到是跳转引发的重复提交。

避免这类问题的方式很直接:只要请求带了请求体,就不要把跳转交给自动机制。自己接管,明确每一次跳转用的什么方法、带不带 body,心里才有数。

import requests sess = requests.Session() resp = sess.post("http://example.com/api/submit", data={"k": "v"}, allow_redirects=False) print(resp.status_code, resp.headers.get("Location"))

2.3 跳转链上 Cookie 与认证头的传递规则

requests用Session时会维护一个CookieJar,跳转后新的请求会从 jar 里按域名匹配取 Cookie。这里的关键是“按域名匹配”——如果跳转跨了域名,比如从a.example.com跳到b.example.com,能不能带上 Cookie 取决于 Cookie 的 domain 属性。设成.example.com的父域 Cookie 会带过去,设成a.example.com的就不会。

Authorization头的行为更严格:requests在构造跳转请求时会检查新旧 URL 的 host 是否一致,一致才保留认证头,跨 host 就剥掉。这是个安全设计,防止认证信息被重定向泄露到第三方。但这个行为有时也会误伤——如果你的服务端把http://api.example.com跳到了http://api2.example.com,认证头丢了,接口直接返回 401,你会以为是 token 过期,其实是跳转导致的。

跳转链上还有个隐蔽的问题:Cookie 的写入时机。如果第一跳的响应里Set-Cookie,第二跳的请求能不能带上?答案是能,requests在每一跳的响应处理里都会调extract_cookies_to_jar,把 Set-Cookie 写进 jar,下一个请求就带上了。这个机制在做登录态联调时特别重要,很多单点登录流程就是靠连续几次跳转里的 Set-Cookie 建立的会话。

2.4 超时参数在跳转链上的真实含义

timeout这个参数很多人以为控制的是“整个请求最多花多久”,实际上它控制的是单次连接或单次读取的超时。一次跳转链上有五次跳转,每一跳都可能耗掉一个timeout的时长,总耗时可能是5 × timeout。你在代码里写了timeout=10,以为最多等 10 秒,结果等了 50 秒才抛异常,这不是 bug,是设计如此。

要控制整条链的总时长,得自己在外面套一层时间预算:

import time import requests deadline = time.monotonic() + 10 sess = requests.Session() url = "http://example.com/entry" for _ in range(5): remain = deadline - time.monotonic() if remain <= 0: raise TimeoutError("跳转链整体超时") resp = sess.get(url, timeout=min(remain, 5), allow_redirects=False) if resp.status_code in (301, 302, 303, 307, 308) and resp.headers.get("Location"): url = resp.headers["Location"] continue break

这段代码的思路是给整条链设一个总预算,每次请求只允许用剩余时间。实际项目里我基本都这么写,尤其是对接外部服务的时候——你永远不知道对方的跳转链会不会卡在某一跳上。

3. 手工接管重定向的完整实现

3.1 什么时候必须关掉自动跳转

自动跳转省事,但有几种场景必须自己接管:需要记录完整跳转链路做审计的、需要在每一跳做校验的、请求带 body 的、跳转目标域名不可控的、以及需要严格控制总耗时的。前三种是业务需求,后两种是安全需求。

举一个典型的安全场景:某个接口返回的Location是完全由用户输入拼出来的,如果不做校验就跟着跳,攻击者可以把请求引到自己的服务器上,顺带把 Cookie 或者认证信息带过去。虽然requests跨 host 会剥掉Authorization,但 Cookie 的规则不一样,父域 Cookie 是会被带走的。自己接管跳转,加一层域名白名单,能挡掉大部分这类风险。

from urllib.parse import urljoin, urlparse ALLOWED_HOSTS = {"example.com", "api.example.com"} def is_allowed(url: str) -> bool: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return False return parsed.hostname in ALLOWED_HOSTS

白名单校验要放在拼完绝对地址之后,因为Location可能是相对路径,直接用原始值判断会漏。同时scheme也要校验,防止从 https 被降到 http,或者被换成file://、javascript:这类协议。

3.2 相对路径与协议相对写法的规范化处理

Location头里的地址有三种常见形态:绝对 URL(https://host/path)、绝对路径(/path/to)、相对路径(path/to或../other)。还有一种历史遗留的协议相对写法(//host/path),表示沿用当前请求的协议。

拼绝对地址的标准做法是urljoin(base_url, location),但这里有个广为人知的坑:urljoin在 base 路径没有尾部斜杠时,会截掉最后一段。比如urljoin("http://a.com/api/v1", "user")得到的是http://a.com/api/user,而不是你以为的http://a.com/api/v1/user。这不是 bug,是 RFC 3986 定义的标准行为,但踩的人特别多。

from urllib.parse import urljoin print(urljoin("http://a.com/api/v1", "user")) # http://a.com/api/user print(urljoin("http://a.com/api/v1/", "user")) # http://a.com/api/v1/user print(urljoin("http://a.com/api/", "/root")) # http://a.com/root print(urljoin("https://a.com/x", "//b.com/y")) # https://b.com/y

实际处理时我的做法是:如果Location以/开头,用urlparse拆出 scheme 和 netloc 重新拼;如果不以/开头,先把当前 URL 的最后一段去掉再拼;如果已经是完整 URL,直接用。虽然代码多几行,但比调urljoin的参数猜行为要来得踏实。

注意:Location是可能为空的,也可能包含空格或者非 ASCII 字符。有些服务端会返回未经编码的中文路径,直接拿去请求会报错,得先做一次quote处理。

3.3 跳数上限、循环检测与链路记录

手工接管跳转的核心就是一个带终止条件的循环。终止条件至少要有三条:跳数超过上限、目标地址已经访问过(防循环)、状态码不再是 3xx。

import requests from urllib.parse import urljoin, urlparse def fetch_with_redirects(url, max_hops=5, timeout=8): sess = requests.Session() visited = [] chain = [] current = url for hop in range(max_hops + 1): resp = sess.get(current, timeout=timeout, allow_redirects=False) chain.append({ "hop": hop, "url": current, "status": resp.status_code, "location": resp.headers.get("Location"), }) if resp.status_code not in (301, 302, 303, 307, 308): return resp, chain location = resp.headers.get("Location") if not location: return resp, chain nxt = urljoin(current, location) if nxt in visited: raise RuntimeError(f"检测到重定向循环: {nxt}") if not is_allowed(nxt): raise RuntimeError(f"跳转目标不在白名单内: {nxt}") visited.append(nxt) current = nxt raise RuntimeError(f"超过最大跳数 {max_hops},链路: {chain}")

chain这个列表是我每次都会留的,出了问题直接甩日志就能定位。它记录的不只是地址,还有每一跳的状态码,这在你怀疑“对方到底跳没跳”的时候特别有用。visited集合用来防循环,注意初始 URL 也要放进去,否则 A→B→A 这种两跳的环可能检测不到。

3.4 用 httpx 和 urllib3 做更细粒度的控制

如果你用的是httpx,它有内置的max_redirects参数,比requests方便:

import httpx with httpx.Client(follow_redirects=True, max_redirects=5, timeout=8.0) as client: resp = client.get("http://example.com/entry") print(len(resp.history), resp.url)

httpx的history是list而不是元组,遍历方式一样。它对Location的解析也做了更严格的校验,遇到畸形的Location会直接抛TooManyRedirects或RemoteProtocolError,不会闷头往下跳。

urllib3层面则是由Retry对象控制的,redirect参数表示允许的最大跳数,默认 3。改大一点能适配某些跳转链很长的老系统,改小一点能让异常早点冒出来:

import urllib3 http = urllib3.PoolManager( retries=urllib3.Retry(total=3, redirect=5, raise_on_redirect=True) ) resp = http.request("GET", "http://example.com/entry")

raise_on_redirect=True的含义是跳数用尽后抛异常而不是返回最后一个 3xx 响应,这个开关在需要明确感知失败时很有用。

4. 服务端视角:如何正确地发出重定向

4.1 301 与 302 的选择会直接影响线上回滚

写客户端代码的人多,写服务端跳转的人也多,但从服务端发跳转时踩的坑一点不少。最典型的就是把该用 302 的地方写成了 301。

某个团队做过一次路径重构,把/old-page用 301 永久重定向到/new-page。上线一周后业务回滚,/old-page重新启用,结果一批用户死活看不到新内容,因为他们的浏览器早就把 301 缓存住了,请求压根没发到服务端。这种情况清理起来极其麻烦,只能引导用户清缓存或者换个域名,成本高得离谱。

我的原则很明确:只要跳转目标有可能变化,一律用 302。301 只用在真正不会再改的场景,比如域名整体迁移、或者旧的 URL 结构永久废弃。用 301 之前先问自己一句:这个地址一年后还有可能改方向吗?答案是“可能”,那就别用。

Flask 里发跳转是这样:

from flask import Flask, redirect, url_for, request app = Flask(__name__) @app.route("/legacy") def legacy(): return redirect(url_for("new_view"), code=302)

redirect的code默认就是 302,很多人不写这个参数然后以为自己是 301,或者反过来。url_for生成的是站内路径,比手写字符串安全,避免拼错。

4.2 307、308 与请求方法保持的代价

307 和 308 保证了方法不变,但也把请求体一起带过去了。如果你的场景是表单 POST 之后跳转,用 307 会让浏览器把整个 POST 体再发一遍到新地址,服务端如果没做幂等,就产生了重复提交。这也是为什么规范里专门定义了 303——它的目的就是让 POST 之后的跳转变成一次干净的 GET。

FastAPI 的RedirectResponse默认是 307,这一点要特别留意。写登录跳转、表单提交后跳转这类逻辑时,如果直接return RedirectResponse("/result"),浏览器会用 POST 去请求/result,而那个路由大概率只注册了 GET,结果就是 405。

from fastapi import FastAPI from fastapi.responses import RedirectResponse app = FastAPI() @app.post("/submit") def submit(): # 默认 307 会保留 POST 方法,这里显式改成 303 return RedirectResponse("/result", status_code=303)

4.3 POST-Redirect-GET 模式的落地细节

表单提交后跳转,业界通用的模式叫 PRG(POST-Redirect-GET)。流程是:浏览器 POST 到处理接口,服务端处理完返回 303,浏览器用 GET 请求结果页。这样做的好处是刷新结果页不会重复提交表单,避免“确认重新提交表单”的弹窗。

实现上有几个细节值得注意。第一,跳转时如果需要传递状态(比如“保存成功”),用 query 参数或者服务端 session,不要用请求体,因为 303 之后请求体已经没了。第二,跳转目标必须是 GET 可访问的路径,别跳到一个只接受 POST 的路由上。第三,如果处理逻辑很慢,别在处理过程中跳转,先返回一个“处理中”页面,用轮询去查结果,否则用户会盯着一个白屏等很久。

还有一种情况是在 REST 接口里做资源创建后的跳转。规范的做法是返回 201 加Location头,指向新资源地址,而不是直接 302 跳过去。客户端拿到Location后自己决定要不要跟,这样语义更清晰。

5. 高频问题排查与踩坑实录

5.1 常见故障速查表

现象可能原因排查动作
请求成功但字段对不上发生了跳转,拿到的是别的页面打印resp.url和resp.history
报TooManyRedirects循环跳转或跳数超限逐跳打印Location,检查是否有 A→B→A
跳转后 401跨域跳转,认证头被剥离检查 host 是否变化,必要时手动补头
跳转后参数丢失301/302 把 POST 改成了 GET关闭自动跳转,手动指定方法
POST 后返回 405服务端用了 307,客户端用 POST 请求了 GET 路由服务端改 303
改了跳转配置但用户没变化301 被客户端缓存换 URL 或改状态码,清缓存无效则需引导
Location解析出错相对路径拼接错误或含非法字符检查urljoin的 base 是否带尾斜杠
总耗时远超 timeouttimeout是单次而非整链加总时间预算控制

5.2 从日志入手定位跳转问题

排查跳转问题,最有效的手段是在请求层面加结构化日志。我通常会在客户端的封装里加这么一段,把每一跳的地址、状态码、耗时、关键响应头都记下来:

import logging import time logger = logging.getLogger("http") def log_chain(resp): for i, r in enumerate(resp.history, 1): logger.info( "hop=%d status=%s url=%s location=%s", i, r.status_code, r.url, r.headers.get("Location"), ) logger.info( "final status=%s url=%s elapsed=%.3fs", resp.status_code, resp.url, resp.elapsed.total_seconds(), )

日志里最值得看的是Location的原始值,而不是拼好的地址。有些服务端会返回大量前导空格的Location,或者带换行符,requests在某些版本里会因此解析失败或者跳到奇怪的地址。看到原始值你才知道到底是谁的问题。

命令行调试时curl -L -v也是好帮手,-v会把每一跳的请求头响应头都打出来,-L让它跟着跳。有个细节是curl默认不保留 POST 方法,需要显式加--post301、--post302这类参数才能保留,跟 Python 库的行为对照着看很有参考价值。

浏览器端的排查就打开开发者工具的网络面板,勾上 Preserve log,否则页面跳转后记录就清空了。这个“勾上保留日志”是很多人第一次抓跳转时不知道的操作,抓了半天什么都没看到。

5.3 并发场景下跳转链的放大效应

单次请求多跳几次没什么感觉,但并发一上来,影响就被放大了。假设你有 200 个并发任务,每个任务平均跳 3 次,那实际发出的请求数是 800 个,而不是 200 个。如果池子里的连接数配置得比较紧,就会出现请求排队,表现为“单个请求测下来很快,压测时吞吐上不去”。

用Session复用连接能缓解这个问题,但要注意Session本身不是线程安全的。多线程场景下要么每个线程一个Session,要么干脆用连接池自己管理。还有个容易被忽略的点是stream=True时跳转的处理——如果响应体很大又开了流式,跳转过程中的中间响应体会被读掉,这个行为在某些版本里会带来额外开销。

我做接口压测时养成的习惯是,先单独跑一遍看跳转链有几跳,把跳数记下来,再乘上并发数去估算实际请求量。如果发现跳数超过两次,基本都会去找服务端聊聊,看看能不能合并或者直接返回最终地址,因为每一跳都是纯开销,没有任何业务价值。

提示:把常用的固定跳转关系缓存下来,比如某短链域名到真实地址的映射,能在高并发下省掉大量无谓的往返。缓存要设过期时间,别把可能变化的跳转关系永久记住。

6. 几个我踩过之后才记住的细节

requests的Session在跨跳转时会重置一些内部状态,但headers里手动设置的固定头不会跟着丢,这一点跟认证头的处理逻辑不一样,容易混淆。我自己封装客户端时会把所有可能受跳转影响的头列一个清单,在每一跳之后显式重设,虽然啰嗦,但从没出过问题。

另一个细节是resp.request和resp.url的区别。resp.request.url是最初发出的那个请求,resp.url是最终落地的地址,调试时两个都打出来,一眼就能看出有没有发生跳转。这个小技巧帮我省过无数次来回确认的时间。

最后一个是关于Location的编码。有些老系统返回的Location里带中文路径但没做 URL 编码,requests在某些版本里会直接抛InvalidURL,而在另一些版本里会自动编码,行为不一致。稳妥的做法是拿到Location后自己做一次规范化处理,把非 ASCII 字符转成百分号编码,再拿去请求。这种处理看起来是多余的防御,但对接过几家老系统之后你就知道,多余的防御往往才是保命的那一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询