Python后端爬虫专题08重试不是再来一次——超时、指数退避与Retry-After上一篇练习完整答案?page2utm_campaignxcity杭州的预期结果为?city%E6%9D%AD%E5%B7%9Epage2。测试应写字面期望不能调用同一个规范化函数生成 expected。循环分页实验中Crawler 的seen_list_pages会让已经访问过的 URL 不再入队max_pages则防止不断产生新 URL 的站点耗尽任务。fromjobradar.urlsimportnormalize_urlassertnormalize_url(https://target.test/jobs,?page2utm_campaignxcity杭州,)https://target.test/jobs?city%E6%9D%AD%E5%B7%9Epage2一次 429 告诉了什么TargetLab/rate-limited第一次返回 429并给出Retry-After: 1。含义不是“立刻再撞一次”而是服务器要求等待。若十个 Worker 同时在一秒内无间隔重试限流会被放大成持续拥塞。JobRadar 对 429、500、502、503、504 做有限重试400、401、403、404 默认不重试因为请求格式、身份、权限或资源不存在不会靠等待修好。是否重试是业务分类不是把所有status 400放进一个循环。退避公式和总预算第 n 次失败后的指数退避是base_delay * 2 ** (n - 1)并受 max_delay 限制。如果响应给出 Retry-After则取服务器要求与本地退避的较大值。课程测试关闭真实 sleep用 SleepRecorder 记录应等待的秒数因此既快又能验证决策。最多尝试 3 次意味着初始请求加最多 2 次重试。变量叫max_attempts而不是retries就是避免“3 到底是总次数还是重试次数”的歧义。总耗时还包括每次连接/读取超时生产任务必须据此设置 deadline。哪些异常可以重试ConnectError、ReadTimeout 可能来自暂时网络问题证书错误、非法 URL 和策略拒绝则不应重试。当前 HttpFetcher 对 RequestError 做有限重试策略错误发生在发送前并直接抛出。生产可进一步细分 ConnectTimeout 与协议错误但前提是每一类都有明确动作。重试只能用于幂等操作。GET 通常可重试POST 若创建订单重放可能重复副作用除非服务端支持幂等键。JobRadar 的任务创建 API 先落库再入队不能简单在客户端不带任务 ID 地反复 POST。为什么要在关闭响应后等待收到 503/429 后代码先aclose()再 sleep否则连接可能一直占在池里其他任务无法使用。失败路径同样需要资源释放这也是使用 async context 和 finally 的原因。Retry-After 除秒数外也可使用 HTTP 日期。解析失败时不能无限等待代码退回本地指数值日期在过去则视为 0。最大延迟仍由 max_delay 约束防止恶意响应让任务休眠数天。运行真正的限流测试.\.venv\Scripts\python.exe-m pytest tests\test_http_client.py::test_fetcher_honors_retry_after_for_rate_limit-q测试第一次返回 429/Retry-After2第二次返回 200断言 sleeper 只记录[2.0]。把代码改成只使用 0.1 秒指数退避测试会失败。另一个测试确保 400 只有一次调用防止“重试越多越可靠”的误解。线上排查要看尝试链日志至少记录 task_id、规范 URL、attempt、状态/异常类型、等待秒数和最终结果不记录 Cookie 与响应全文。指标要区分fetch_attempts与pages_succeeded否则一个页面重试三次会被误认为抓了三个页面。大量 429 应先降低并发和频率、联系目标方而不是调大重试次数大量连接超时则检查 DNS、代理、目标健康和连接池。重试是短暂故障的恢复手段不是隐藏容量或授权问题的橡皮擦。本篇完整 HTTP 模块重点阅读 RetryPolicy.delay_for 和_request_with_retry。策略对象只计算HttpFetcher 才执行等待这样测试无需真的 sleep。响应是否可重试由集合明确列出新增状态码应伴随业务理由和测试。具有总量限制、有限重试和重定向复检的 HTTP 客户端。importasynciofromcollections.abcimportAwaitable,Callablefromdataclassesimportdataclassfromemail.utilsimportparsedate_to_datetimefromdatetimeimportdatetime,timezoneimporthttpxfrom.policyimportCrawlPolicyfrom.urlsimportnormalize_urlclassFetchError(RuntimeError):目标页面在有限尝试内没有得到可用响应。classResponseTooLarge(FetchError):响应超过任务配置的内存预算。dataclass(frozenTrue)classRetryPolicy:max_attempts:int3base_delay:float0.5max_delay:float30.0retry_statuses:frozenset[int]frozenset({429,500,502,503,504})def__post_init__(self)-None:ifself.max_attempts1:raiseValueError(max_attempts must be at least 1)ifself.base_delay0orself.max_delay0:raiseValueError(retry delays must be non-negative)defdelay_for(self,failed_attempt:int,retry_after:str|None)-float:server_delay_parse_retry_after(retry_after)exponentialself.base_delay*(2**(failed_attempt-1))returnmin(self.max_delay,max(exponential,server_delayor0.0))dataclass(frozenTrue)classFetchResult:url:strstatus_code:intbody:bytesheaders:dict[str,str]encoding:strnot_modified:boolFalsepropertydeftext(self)-str:returnself.body.decode(self.encoding,errorsreplace)propertydefetag(self)-str|None:returnself.headers.get(etag)propertydeflast_modified(self)-str|None:returnself.headers.get(last-modified)def_parse_retry_after(value:str|None)-float|None:ifnotvalue:returnNonetry:returnmax(0.0,float(value))exceptValueError:try:retry_atparsedate_to_datetime(value)except(TypeError,ValueError,OverflowError):returnNoneifretry_at.tzinfoisNone:retry_atretry_at.replace(tzinfotimezone.utc)returnmax(0.0,(retry_at-datetime.now(timezone.utc)).total_seconds())classHttpFetcher:执行真实下载解析器与数据库不会直接依赖 HTTPX。def__init__(self,client:httpx.AsyncClient,policy:CrawlPolicy,retry_policy:RetryPolicy,*,max_response_bytes:int2*1024*1024,max_redirects:int5,sleeper:Callable[[float],Awaitable[None]]asyncio.sleep,resolver:Callable[[str,int],Awaitable[set[str]]]|NoneNone,)-None:ifmax_response_bytes1:raiseValueError(max_response_bytes must be positive)ifmax_redirects0:raiseValueError(max_redirects must be non-negative)self._clientclient self._policypolicy self._retryretry_policy self._max_response_bytesmax_response_bytes self._max_redirectsmax_redirects self._sleepersleeper self._resolverresolverasyncdeffetch(self,url:str,*,etag:str|NoneNone,last_modified:str|NoneNone,)-FetchResult:下载一个页面每次重定向都重新执行目标策略检查。headers{Accept:text/html,application/xhtmlxml}ifetag:headers[If-None-Match]etagiflast_modified:headers[If-Modified-Since]last_modified current_urlurl redirects0whileTrue:self._policy.check_url(current_url)ifself._resolverisnotNone:partshttpx.URL(current_url)addressesawaitself._resolver(parts.host,parts.portor(443ifparts.schemehttpselse80),)self._policy.check_resolved_addresses(current_url,addresses)responseawaitself._request_with_retry(current_url,headers)ifresponse.status_codein{301,302,303,307,308}:locationresponse.headers.get(location)awaitresponse.aclose()ifnotlocation:raiseFetchError(fHTTP{response.status_code}has no Location header)redirects1ifredirectsself._max_redirects:raiseFetchError(fredirect limit exceeded:{self._max_redirects})current_urlnormalize_url(current_url,location)continuereturnawaitself._consume(response,current_url)asyncdef_request_with_retry(self,url:str,headers:dict[str,str])-httpx.Response:forattemptinrange(1,self._retry.max_attempts1):requestself._client.build_request(GET,url,headersheaders)try:responseawaitself._client.send(request,streamTrue,follow_redirectsFalse)excepthttpx.RequestErrorasexc:ifattemptself._retry.max_attempts:raiseFetchError(fnetwork error after{attempt}attempt(s):{type(exc).__name__})fromexcawaitself._sleeper(self._retry.delay_for(attempt,None))continueifresponse.status_codeinself._retry.retry_statuses:retry_afterresponse.headers.get(retry-after)awaitresponse.aclose()ifattemptself._retry.max_attempts:raiseFetchError(fHTTP{response.status_code}after{attempt}attempt(s))awaitself._sleeper(self._retry.delay_for(attempt,retry_after))continueifresponse.status_code400andresponse.status_code!304:statusresponse.status_codeawaitresponse.aclose()raiseFetchError(fHTTP{status}is not retryable)returnresponseraiseAssertionError(retry loop must return or raise)asyncdef_consume(self,response:httpx.Response,url:str)-FetchResult:headers{key.casefold():valueforkey,valueinresponse.headers.items()}encodingresponse.encodingorutf-8ifresponse.status_code304:awaitresponse.aclose()returnFetchResult(urlurl,status_code304,bodyb,headersheaders,encodingencoding,not_modifiedTrue,)bodybytearray()try:asyncforchunkinresponse.aiter_bytes():body.extend(chunk)iflen(body)self._max_response_bytes:raiseResponseTooLarge(fresponse exceeds{self._max_response_bytes}bytes)finally:awaitresponse.aclose()returnFetchResult(urlurl,status_coderesponse.status_code,bodybytes(body),headersheaders,encodingencoding,)本篇课后练习写出 base_delay0.5、连续三次失败时的两次等待并说明为何第三次失败后不再等。增加 HTTP-date 格式 Retry-After 的测试使用接近当前时间的日期时允许小范围误差。模拟 400、503、429 三种响应画出各自调用次数和结束状态。下一篇将让访问范围和 robots 规则在请求前生效。
