客户端跟随重定向时,需要先读取 Location,解析目标地址,再发出后续请求,才能取得需要的表示。几个单独看来合理的规则经常组合成长链:规范主机名、切换到 HTTPS、补齐末尾斜线、迁移旧文章。检查时应从用户实际访问的旧地址出发,沿完整路径观察,因为每一个中间环节都会增加依赖。

请求次数不等于连接次数

实际从网络读取的新重定向通常增加一次顺序发生的请求与响应交换。客户端要等前一个响应给出目标,才能继续访问,所以它会推迟最终内容的获取。但“再发请求”不等于“必须重新建立 TCP 连接”。同源 HTTP/1.1 可以复用持久连接,HTTP/2 和 HTTP/3 则有各自的连接与流机制。切换主机可能额外触发域名解析或建连,是否真的发生还取决于已有连接和缓存。

浏览器内部升级和缓存重定向也会改变网络轨迹。HSTS 可能直接把 HTTP 地址升级为 HTTPS,不必先请求明文入口;永久重定向也可能已经存储在缓存。因此,不能为每一跳规定固定耗时,更不能直接断言三个重定向就让整页加载慢三倍。图片、脚本及渲染都可能占据更多时间,必须结合真实瀑布图判断关键路径。

状态码首先表达语义。301 和 302 的历史处理允许把 POST 改为 GET,307 与 308 要求保留方法,303 则指向另一个资源的检索。永久性与可缓存性同样需要考虑。为了把请求列表变短而统一改成永久跳转,可能让客户端长期记住一次错误配置,即使服务器随后修复也不能立即消除影响。

Location 可以使用相对引用,必须结合当前请求地址解析后,再判断目标源和路径是否符合预期。只记录原始响应头可能掩盖问题:相同的相对值从不同目录返回,最终会指向不同位置。故障记录应同时保留原始值和解析结果。

坏掉的样子

两个规则可能互相反向,例如一个规则添加斜线,另一个又删除斜线。分别检查两个端点都能收到响应,并不能证明访问最终会结束。客户端通常有跳转次数上限,但触及上限依然意味着用户任务失败。修改共享规则后,需要记录访问过的目标,并覆盖具有代表性的旧地址、带参数地址及不同入口。

中间地址失效时,最终页面即使健康也无法保证整条路径可达。可以把内部链接更新为最终规范地址,并在语义允许时让历史地址直接跳过去。修改过程中仍要保留有业务意义的查询参数和编码,不能因为少一跳就丢失筛选条件。也不要接受完全不受约束的用户目标地址,或把身份凭据无条件转发到另一个源。

抓取程序同样需要处理额外请求,但重定向链并不能换算成固定的搜索排名损失。搜索引擎有独立的处理规则和上限,本地实验没有测量这些行为。可报告的是跳转数量、是否循环、最终状态及目标是否一致;这些证据比对排名作没有依据的保证更适合指导修复。

快速验证

将下面程序保存为 redirect-chain.py,运行 python3 redirect-chain.py。程序使用 Python 3 创建 HTTP/1.1 回环服务器,并始终使用同一个 HTTPConnection。每个响应都有明确长度,客户端完整读完后可以继续复用连接。响应设置 no-store,使实验观察实际网络交换,而不是依赖缓存里的跳转记录。

from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import urljoin, urlsplit
import json

events = []
routes = {'/long': '/middle', '/middle': '/final', '/short': '/final',
          '/loop-a': '/loop-b', '/loop-b': '/loop-a'}


class Handler(BaseHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'

    def do_GET(self):
        events.append({'path': self.path, 'peer_port': self.client_address[1]})
        status = 302 if self.path in routes else 200 if self.path == '/final' else 404
        data = b'final representation' if status == 200 else b''
        self.send_response(status)
        if status == 302:
            self.send_header('Location', routes[self.path])
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
try:
    origin = f'http://127.0.0.1:{server.server_port}'

    def follow(start):
        path, seen, trail = start, set(), []
        for _ in range(5):
            if path in seen:
                raise ValueError('redirect loop')
            seen.add(path)
            connection.request('GET', path)
            response = connection.getresponse()
            body = response.read()
            trail.append([path, response.status])
            if response.status == 200:
                assert body == b'final representation'
                return trail
            assert response.status in (301, 302, 303, 307, 308)
            target = urlsplit(urljoin(origin + path, response.getheader('Location')))
            assert (target.scheme, target.netloc) == ('http', urlsplit(origin).netloc)
            path = target.path + ('?' + target.query if target.query else '')
        raise ValueError('redirect budget exhausted')

    long_path = follow('/long')
    short_path = follow('/short')
    assert len(long_path) == 3 and len(short_path) == 2
    try:
        follow('/loop-a')
    except ValueError as error:
        assert str(error) == 'redirect loop'
    else:
        raise AssertionError('loop accepted')
    connections = len({event['peer_port'] for event in events})
    assert connections == 1 and len(events) == 7
    print(json.dumps({'long_path': long_path, 'short_path': short_path,
                      'loop_rejected': True, 'requests': len(events),
                      'observed_tcp_connections': connections}))
finally:
    connection.close()
    server.shutdown()
    thread.join()
    server.server_close()

预期长路径 /long → /middle → /final 包含三次请求,短路径 /short → /final 包含两次。循环从 /loop-a 到 /loop-b 后,在再次请求第一个目标之前被识别并拒绝。服务端总计记录七次请求,而且只观察到一个对端连接。这些断言已经在 Python 3.14.4 下执行通过;随后连接和服务器都会关闭,不留下运行中的服务。

本例使用 GET 和临时 302 响应,只验证请求计数及连接复用。它没有测量延迟,没有测试 POST 改写、TLS、跨源身份处理或搜索引擎结果。客户端刻意限制同源目标和很小的跳转预算,并不是可直接替代通用网络库的实现。实际修改入口规则时,还要针对方法、参数、主机和旧地址保持做验证;更短的路线只有在仍然到达正确表示并保留所需语义时才有价值。

延伸阅读

来源