ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

打造打不垮的API调用:超时重试退避熔断全指南

2026/9/4 10:24:37 拓冰建站 浏览量
打造打不垮的API调用:超时重试退避熔断全指南 先说句实话我现在看到项目里直接裸调 API 就怕。接口调用失败这件事大多数时候问题不是出在提供方而是调用方基础逻辑太糙——超时不设、状态码不看、失败后就地崩溃。后来我也踩够了干脆封装了一个带重试、退避、熔断和流式收尾的 API 调用函数。这套代码目前在部门里被多个脚本和后台任务用着最直观的变化是以前半夜报警“调用失败”现在只剩真·业务异常才会打扰人。这篇文章不是讲高大上架构就是给你一个能直接复制回来改的“打不垮”版本。适合三类人看写爬虫和脚本的、接大模型接口做工具的、还有批量调度任务里整天被偶发 502 折磨的后端。1. 先想明白什么样的错误值得重试什么样的错误重试也没用写封装之前最忌讳一上来就while Truetry except那样不是打不垮是把自己做成一台无脑撞墙机器。先说一个核心判断不是所有异常都应该被处理很多异常重试 100 次结果一模一样纯粹浪费请求和时间。我习惯把失败先分成四类来看。1.1 网络层异常连接拒绝、DNS解析失败、连接中断这类问题通常是瞬时的比如对方服务的网关刚好在发布、机房网络抖动、本机 DNS 缓存暂时抽风。对于这种情况隔几百毫秒到几秒再试一次成功率往往会明显回升。requests 里面的典型表现是ConnectionError建立连接阶段就失败Timeout连接超时或读取超时ChunkedEncodingError响应头收到了但 body 读到一半连接被掐它们的特点是请求可能根本没到达服务端或者服务端还没开始处理所以重试的代价比较低。1.2 HTTP 状态码中的“暂时性错误”408、425、429、500、502、503、504这里要特别强调 429。很多API会做限流一旦超过阈值就返回 429聪明一点的响应里还会带Retry-After头告诉你“过几秒再试”。如果业务侧完全不理会 429只是一味重试那你不仅得不到数据还会被封得更狠。5xx 一类的状态码比如 500、502、503、504大概率是服务端自己出了问题或者负载过高这时候重试通常是合理的。但注意重试频率和总次数一定要控制否则就是给已经脆弱的服务继续施加压力。1.3 请求本身错误400、401、403、404、409、422这类状态码代表的是请求参数、鉴权、权限、资源状态出了问题。比如模型 ID 传错了、API Key 过期了、参数格式不合法、某个资源已经被删除。这种错误无论重试多少次都一样属于永远不可能成功的请求。我在代码里专门做了一个NonRetryableError遇到这类就直接抛出让上层业务立即知道而不是糊里糊涂地重试 N 次以后才放弃。真实项目里最怕的就是下游本来想让你知道“你的 key 欠费了”你这边还傻傻重试半小时白白浪费时间和金钱。1.4 内容层错误返回 200 但解析失败或业务码失败这一层容易被忽视。有些老接口无论逻辑多错都返回 HTTP 200只是 body 里带一个code: 5001如果你只看 HTTP 状态码很容易误判为成功。处理方式是把“业务失败码”也纳入决策。比如某些大模型接口在服务端超时后会返回带code的错误 JSON某些文件处理接口返回status: failed。在call_api函数外面建议加一个回调或者约定一个is_business_error函数把这类情况也映射成可重试或不可重试。先列一个表方便随时翻失败类型典型例子是否应该重试原因网络层失败DNS错误、连接拒绝、连接中断是大多是瞬时问题请求超时连接超时、读超时视幂等性而定可能服务端已处理重试会造成重复限流HTTP 429是但要遵守 Retry-After等限流窗口过去服务端问题500、502、503、504是暂时性故障概率大参数/权限错误400、401、403、404否请求本身不可能成功内容解析失败200 但 JSON 解析失败可先重试一次可能响应不完整或网关给了一段 HTML业务错误码code: 10001 余额不足否等了也不会自动解决1.5 幂等性这个前提必须摆在桌面上还有一个前置问题如果重试第二次请求会不会产生副作用比如调用支付接口、创建订单、上传文件如果你只是简单地把原始请求重发一遍很可能造成重复扣款、重复下单。对这种场景不能把“自动重试”做成默认选项要么让调用方显式声明idempotentTrue要么在请求头里传幂等键常见的如Idempotency-Key: request-uid-xxx。判断标准很简单这个请求发出后哪怕服务端已经成功处理只是因为响应丢了我再补发一次会不会出事如果不会那就可以放心自动重试如果会那就必须引入幂等键否则你所谓的“打不垮”是在替项目制造更大的麻烦。2. 单纯多试几次不够退避、抖动和熔断才是关键把“什么时候该重试”想清楚以后接下来才是重试策略本身。这里最容易犯的错是固定延时循环比如每次失败等 1 秒再试。固定延时在小规模请求下没毛病但一旦同时有几十个任务都失败它们的重试节奏会完全同步造成“惊群效应”每 1 秒大家一起打一次接口很容易再次触发服务端限流或雪崩。2.1 指数退避为什么要存在指数退避是业界最基础的做法第一次失败后等 1 秒第二次失败后等 2 秒第三次等 4 秒第四次等 8 秒。给服务端更多恢复时间也让自己的重试不要那么密集。公式大概是delay min(base_delay * 2 ** (attempt - 1), max_delay)其中attempt是从第 1 次失败开始计数也就是第 1 次失败后等base_delay第 2 次失败后等2 * base_delay。max_delay是上限防止退避时间无限变大。2.2 抖动到底是什么又是干嘛用的指数退避还远远不够。假设 base_delay 1 秒有 30 个线程同时碰上了服务端抖动第一次失败后它们可能都在第 1 秒左右重试等于把刚刚还脆弱的上游又打了 30 次。解决方式是在退避时间基础上加一个随机值叫“抖动jitter”。最常见的做法是delay min(base_delay * 2 ** (attempt - 1), max_delay) delay delay random.uniform(0, jitter_ratio * delay)jitter_ratio取 0.2 到 0.5 之间比较常用。加了这个随机量以后同样失败的 N 个请求不会在同一点齐刷刷地发起重试而是自然地分散开。2.3 熔断不是每个函数都要但高并发场景必须有“打不垮”不代表“永远在打”。如果你的服务正在经历大面积超时下游已经明显过载此时所有调用方如果依然按照重试策略拼命重试只会让故障雪上加霜。这种时候需要的是熔断连续失败次数达到阈值后直接快速失败不再发起新请求等过一段时间再放少量试探请求确认下游恢复后再逐渐放开。给一个简单比喻一个人已经生病了你一遍遍按门铃问“你好点了吗”他只会更难受。最好的办法是让他安静休息半小时半小时后再去敲门问一次能开门就说明恢复了。熔断状态一般有三种closed正常放行请求open熔断打开直接拒绝不再请求下游half_open熔断恢复前的试探阶段允许少量请求通过至于“连续失败多久算故障”每家服务不一样。普通的内部API我一般设连续失败 5 次触发熔断熔断恢复时间是 30 秒大模型接口这种单次调用成本高的熔断条件更严格一点但恢复时间可以保持在 30 到 60 秒。3. 完整代码先把一个普通 JSON 接口的调用函数做扎实下面这个函数面向的是绝大多数普通 API 场景POST 或 GET 发出去返回 JSON。它包含指数退避、随机抖动、超时控制、有限重试、状态码分流、熔断开关和重试回调。直接复制到一个api_guard.py文件里就能用。3.1 公共配置与断路器import random import threading import time from dataclasses import dataclass from typing import Optional import requests DEFAULT_RETRIABLE_STATUS frozenset({408, 425, 429, 500, 502, 503, 504}) dataclass(frozenTrue) class RetryPolicy: max_attempts: int 4 base_delay: float 1.0 max_delay: float 30.0 jitter: float 0.3 timeout: Optional[tuple] None retriable_status: frozenset DEFAULT_RETRIABLE_STATUS retry_on_timeout: bool True retry_on_connection_error: bool True total_timeout: float 300.0 class CircuitOpenError(RuntimeError): pass class RetryableHTTPError(RuntimeError): def __init__(self, status_code: int, text_snippet: str ): super().__init__(fHTTP {status_code}: {text_snippet[:200]}) self.status_code status_code self.text_snippet text_snippet[:200] class NonRetryableError(RuntimeError): pass class RetryExhausted(RuntimeError): pass