春江暮客

春江暮客的个人学习分享网站

Python HTTP JSON 请求:超时、有限重试与本地失败测试

2026-10-12 技术
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 数据,才把这次请求计为成功。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor automation bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena copy cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization debugging deployment desktop-app devtools disk-space disown docker dovecot download electron esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp faulthandler ffmpeg file-io flashppi flask folium frontend game generator git github-actions google google-research grep harness hls html http hugo indexnow javascript jev json just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp mirror mmcif model-evaluation mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pdb pep-723 phaser pillow pip postfix preprocessing print protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 signals sklearn solana somaticsignatures spl sqlite ssh standardize static-site subprocess sysadmin system-one tensorflow tkinter tron tronpy troubleshooting turtle typesafe-ai ubuntu usdt uv vhhbert vite webp wordcloud wordpress workflow yaml 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它