Python HTTP JSON 请求:超时、有限重试与本地失败测试
定时脚本请求一个 JSON 接口,偶尔遇到 503 就整批失败;加上无限重试,又可能一直等下去。本文用 Python 标准库实现一个小型 GET 客户端:设置超时、限制尝试次数,并在本地复现成功与失败。
示例面向 Linux 上的单进程自动化脚本,也可在其他提供 Python 的系统运行。它读取不超过 1 MiB 的 UTF-8 JSON,不需要 API 密钥,也不需要安装第三方包。
1. 先确定哪些错误可以重试
这里采用一个明确的策略:
| 结果 | 行为 |
|---|---|
| HTTP 200 且 JSON 可解析 | 返回数据。 |
| 502、503、504 | 最多尝试指定次数;等待 0.5、1、2 秒,之后继续翻倍但上限为 8 秒。 |
| 连接或读取超时 | 使用同样的有限重试。 |
| 其他 HTTP 状态、无效 JSON、响应过大 | 立即失败。 |
可重试状态带有 Retry-After |
停止当前任务,交给调用方按服务要求延后。 |
| DNS、证书或其他连接错误 | 立即失败,先检查配置。 |
--attempts 3 包含第一次请求,所以最多只有两次重试。这个工具只做 GET;把同样逻辑套在提交订单、转账或其他写入操作上,可能重复执行。HTTP 对幂等方法与自动重试的说明见 RFC 9110 第 9.2.2 节。
2. 准备脚本并启动本地服务
需要 Python 3.10 或更新版本和 curl。下面的示例已在 Linux、Python 3.11.4 上验证,命令适用于 bash 或 zsh。在新的练习目录运行:
python3 --version
mkdir http-json-retry-demo
cd http-json-retry-demo
curl -fS https://www.bobobk.com/downloads/http-json-retry/get_json.py -o get_json.py
curl -fS https://www.bobobk.com/downloads/http-json-retry/demo_server.py -o demo_server.py
python3 get_json.py --help
python3 demo_server.py --port 8765
服务只监听 127.0.0.1。这个终端保持运行;在第二个终端进入同一个 http-json-retry-demo 目录。结束练习时,在服务终端按 Ctrl+C。
客户端默认使用系统代理配置。如果本地测试请求也被送到代理,在第二个终端设置:
export no_proxy=127.0.0.1,localhost
export NO_PROXY=127.0.0.1,localhost
代理绕过规则可参考 Python 的 ProxyHandler 文档。这两个设置只作用于当前终端及其子进程。
3. 客户端的完整实现
下载文件与下面代码相同,也可以直接保存为 get_json.py:
"""Fetch a small HTTP JSON document with bounded GET retries. Python 3.10+."""
import argparse
import json
import math
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
MAX_BYTES = 1024 * 1024
RETRY_STATUS = {502, 503, 504}
def get_json(url, *, timeout=3.0, attempts=3):
parts = urlsplit(url)
if parts.scheme not in {"http", "https"} or not parts.hostname:
raise ValueError("URL must use http:// or https:// with a host")
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be finite and greater than zero")
if not isinstance(attempts, int) or not 1 <= attempts <= 10:
raise ValueError("attempts must be between 1 and 10")
for attempt in range(1, attempts + 1):
request = Request(url, headers={"Accept": "application/json"}, method="GET")
try:
with urlopen(request, timeout=timeout) as response:
if response.status != 200:
raise ValueError(f"expected HTTP 200, got {response.status}")
body = response.read(MAX_BYTES + 1)
if len(body) > MAX_BYTES:
raise ValueError("response exceeds 1 MiB")
return json.loads(body.decode("utf-8"))
except HTTPError as exc:
status = exc.code
retry_after = exc.headers.get("Retry-After")
exc.close()
if status not in RETRY_STATUS or attempt == attempts:
raise
if retry_after is not None:
raise ValueError(f"HTTP {status} with Retry-After; defer this job") from exc
reason = f"HTTP {status}"
except URLError as exc:
if not isinstance(exc.reason, TimeoutError) or attempt == attempts:
raise
reason = "timeout"
except TimeoutError:
if attempt == attempts:
raise
reason = "timeout"
delay = min(0.5 * 2 ** (attempt - 1), 8.0)
print(
f"RETRY attempt={attempt}/{attempts} reason={reason} delay={delay:.1f}s",
file=sys.stderr,
flush=True,
)
time.sleep(delay)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("url")
parser.add_argument("--timeout", type=float, default=3.0, help="blocking timeout in seconds")
parser.add_argument("--attempts", type=int, default=3, help="total attempts, including first")
args = parser.parse_args()
try:
result = get_json(args.url, timeout=args.timeout, attempts=args.attempts)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
HTTPError 是 URLError 的子类,所以先处理 HTTP 状态,再处理网络错误;错误响应也要关闭。异常关系见 Python 的 urllib.error 文档。
这里把解析错误放在重试范围之外。服务返回 HTML 错误页、空正文或损坏的 JSON 时,应检查接口与响应内容。Accept: application/json 只是请求偏好,并不能保证服务返回 JSON。
4. 复现两次 503 后恢复
在第二个终端运行:
python3 get_json.py http://127.0.0.1:8765/flaky --timeout 1 --attempts 3
服务启动后,/flaky 的前两次请求返回 503,第三次返回成功。重试消息写入标准错误,JSON 写入标准输出;终端通常显示:
RETRY attempt=1/3 reason=HTTP 503 delay=0.5s
RETRY attempt=2/3 reason=HTTP 503 delay=1.0s
{
"ok": true,
"items": 3
}
再次请求 /flaky 会直接成功。要重做这段演示,先重启测试服务,让计数归零。
把合法 JSON 与预期数据分开验证:
python3 get_json.py http://127.0.0.1:8765/ok > result.json
python3 -c 'import json; data=json.load(open("result.json", encoding="utf-8")); assert data == {"ok": True, "items": 3}; print("PASS: expected data")'
这是练习目录中的输出文件,重定向会覆盖它,失败时也可能留下空文件。真实任务应先检查退出码和数据,再替换已有结果。部署网页的状态码与正文检查可以参考 curl 部署验证教程。
5. 验证错误不会无限重试
逐条运行以下命令;它们预期返回退出码 1。在 bash 或 zsh 中,紧接着运行 echo $? 可以查看退出码。不要把这段演示放进遇到首个错误就停止的脚本。
python3 get_json.py http://127.0.0.1:8765/missing
python3 get_json.py http://127.0.0.1:8765/bad
python3 get_json.py http://127.0.0.1:8765/always503 --attempts 3
python3 get_json.py http://127.0.0.1:8765/slow --timeout 0.2 --attempts 2
| 路径 | 应看到的结果 |
|---|---|
/missing |
ERROR: HTTP Error 404: Not Found,没有 RETRY。 |
/bad |
JSON 解析错误,没有 RETRY。 |
/always503 |
两条 RETRY,最后报告 HTTP 503。 |
/slow |
一条超时 RETRY,第二次超时后退出。 |
超时的错误文字可能随 Python 与操作系统变化。不要只匹配一句错误文字;同时检查退出码、重试次数和是否产生了预期数据。
6. 本地测试服务的完整代码
以下内容保存为 demo_server.py,与下载文件一致:
"""Local HTTP failure fixture; never expose this as a production service."""
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import threading
import time
class Handler(BaseHTTPRequestHandler):
flaky_count = 0
lock = threading.Lock()
def do_GET(self):
status, body = 200, b'{"ok": true, "items": 3}'
if self.path == "/flaky":
with Handler.lock:
Handler.flaky_count += 1
count = Handler.flaky_count
if count <= 2:
status, body = 503, b'{"error": "temporary"}'
elif self.path == "/always503":
status, body = 503, b'{"error": "unavailable"}'
elif self.path == "/slow":
time.sleep(2)
elif self.path == "/bad":
body = b'not json'
elif self.path != "/ok":
status, body = 404, b'{"error": "missing"}'
try:
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass # The timeout demo can close the client socket first.
def log_message(self, format, *args):
pass
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
with ThreadingHTTPServer(("127.0.0.1", args.port), Handler) as server:
print(f"Serving http://127.0.0.1:{args.port}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
/slow 等待两秒后才发送响应。线程服务允许第二次请求独立进入,不会被第一次等待挡住。客户端先超时断开时,测试服务忽略预期的断开连接错误。
这个服务只用于本地练习。Python 的 http.server 文档说明了该模块的适用范围与限制。
7. 排查与使用边界
| 问题 | 处理方法 |
|---|---|
Address already in use |
用 python3 demo_server.py --port 8766,并把客户端 URL 的端口改为 8766。 |
| 本地请求连接失败 | 确认服务终端仍在运行、端口一致,并设置上面的 no_proxy。 |
| 401、403、404 | 修正认证、权限或 URL;这个工具不会反复尝试。 |
429 或 Retry-After |
查看服务的限流规则,把任务安排到允许的时间;本例不解析这个头。 |
| 响应超过 1 MiB | 使用分页或流式下载;不要直接取消保护来读取任意大响应。 |
| 超时后仍然运行很久 | 检查是否一直收到少量数据,以及 DNS、代理或系统调用的耗时。 |
urlopen(timeout=...) 限制的是阻塞操作,不是整个任务的总时长上限,见 Python 超时参数说明。重试等待也会增加耗时。需要硬性进程时限时,可结合 子进程超时与日志工具。
客户端默认跟随 urllib 支持的重定向,只适合请求可信接口。解析成功并不等于业务结果正确,接入实际流程时仍需校验字段与类型。这里没有持久缓存、随机抖动或并发限速;多个工作进程同时调用时,需要按服务约定统一控制速率。
长期运行时,将标准错误接入任务日志,并按 Python 日志轮转教程设置保留规则。本例验证了恢复成功、404、无效 JSON、重试耗尽、超时,以及参数与响应大小边界。
8. 小结
先区分临时失败与配置错误,再设置总尝试次数和等待间隔。用本地服务验证失败路径,确认脚本会按预期停止。最后检查实际 JSON 数据,才把这次请求计为成功。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-http-json-timeout-retry.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。