
定时下载报表时,经常有人把curl的进程退出码为0当作"文件已经正确下载"。这个判断遗漏了一层:服务器可能返回404错误页,而curl已经完整传输了这张错误页。传输工具完成工作,不代表拿到的是业务文件。
本篇用curl 8.5.0做一个完全本地的对照,不访问真实客户接口,也不关闭TLS验证。Python标准库启动回环HTTP服务器,随机分配端口,提供正常内容、缺失资源和延迟响应。输入都是合成数据,实验结束关闭服务器并清理临时文件,结果不受外部网站临时状态影响。
1. 用同一服务器制造三个分支
正常路径返回200和complete data,缺失路径返回404和not found。延迟路径先等待0.4秒,再返回正常内容。这是人工设计的慢响应,不是宣称发生了真实网络故障。
python
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path == '/slow':
time.sleep(0.4)
code = 404 if self.path == '/missing' else 200
body = b'not found\n' if code == 404 else b'complete data\n'
self.send_response(code)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
用ThreadingHTTPServer绑定127.0.0.1和端口0,启动后台线程,再通过server_port取得端口。测试的curl命令添加--noproxy '*',只是让这个本地实验不走环境代理;在生产下载中不要不加思考地复制这项设置。它既不是关闭HTTPS校验,也不是解决外网连接问题的方法。
2. 比较普通下载与严格HTTP失败检测
以下两条命令输出响应码,把响应体放进独立临时文件。第一条没有HTTP失败开关,第二条增加--fail-with-body。实际复现时将PORT替换为本地服务器的随机端口。
bash
curl --noproxy '*' --silent --show-error --max-time 2 \
--write-out '%{http_code}' --output naive.tmp \
http://127.0.0.1:PORT/missing
curl --noproxy '*' --silent --show-error --fail-with-body \
--max-time 2 --write-out '%{http_code}' --output strict.tmp \
http://127.0.0.1:PORT/missing
Python用subprocess.run的参数列表调用命令,读取returncode作为进程码,stdout作为--write-out输出,临时文件作为响应体。每个分支都核对预期码和字节内容,不把"命令没有抛异常"当作断言。
3. 实测证明错误页也会被保存

text
normal: exit=0 http=200 body_bytes=14
naive_404: exit=0 http=404 body_bytes=10
strict_404: exit=22 http=404 body_bytes=10
timeout: exit=28 http=000 body_bytes=0
ALL_ASSERTIONS_PASSED
普通404分支进程码仍为0,响应体是10字节的错误文本。严格分支返回22,但同样保留10字节,这恰恰是fail-with-body的用途:允许保留服务器错误信息用于诊断,同时让脚本能够识别HTTP失败。1 若不想保留错误体,可以研究--fail;不要把两个互斥选项同时加上。
延迟分支把--max-time设成0.1秒,实际返回28;服务器尚未发送响应头,write-out显示000。这里000不是服务器的HTTP状态码,只表示curl没有得到可报告的响应码。退出码28也不代表一定发生在连接阶段,它是本次时间限制下的超时结果。
4. 临时输出不能直接当成正式交付
这个实验让我更重视下载后的第二道门:先确认进程码和预期HTTP码,再检查格式与业务内容,最后才能移动临时文件。只加fail-with-body仍可能留下错误页,因此非零退出时不能把临时文件直接交给后续解析器。正常200也可能返回登录页或空报表,必须另做JSON结构、CSV表头或预期签名检查。
下载目标同样不应直接覆盖正在使用的正式文件。每个尝试写到独立临时路径,验证通过才提交;失败时把诊断信息与正式数据分开。不要在日志中原样输出带访问令牌的URL、认证头或敏感响应体,这与是否使用GPT生成脚本无关。
使用gpt68.com等第三方AI会员服务获取代码建议时,仍需要自己的故障样本;它不是模型厂商官网。本文没有调用真实业务接口,也没有虚构模型对话,部分内容由AI辅助生成,实验输出已在本地实际核验。
最后还有两个边界:这里没有使用--location,所以不测试重定向;也没有开启--retry。若将来增加重试,max-time是每次传输的上限,不能据此宣称总任务只运行同样时长。1 测试服务器采用线程处理请求,延迟分支结束后shutdown、server_close和线程join都执行,避免留下占用端口的常驻测试服务。
在自动化脚本中,建议把三份证据分别命名:进程退出码、服务器HTTP响应码、响应体校验结果。不要混用一个success变量,否则后续维护者很难定位究竟在哪层失败。临时输出文件即使是零字节也不是超时的唯一标志;响应头到达以后再超时,可能留下部分内容。这次样本故意在发头之前等待,所以只验证了这一种明确故障顺序,不能推导所有超时都会得到000。
完整本地复现脚本
将下面脚本保存为reproduce.py,用Python 3.12运行;所有断言通过会输出ALL_ASSERTIONS_PASSED。
python
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
import threading,tempfile,subprocess,time
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_GET(self):
if self.path=='/slow':time.sleep(0.4)
code=404 if self.path=='/missing' else 200
body=b'not found\n' if code==404 else b'complete data\n'
self.send_response(code);self.send_header('Content-Length',str(len(body)));self.end_headers()
try:self.wfile.write(body)
except (BrokenPipeError,ConnectionResetError):pass
s=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=s.serve_forever,daemon=True);thread.start()
try:
with tempfile.TemporaryDirectory() as d:
def run(label,path,strict=False,max_time='2'):
target=Path(d)/(label+'.tmp');argv=['curl','--noproxy','*','--silent','--show-error','--max-time',max_time,'--write-out','%{http_code}','--output',str(target)]
if strict:argv+=['--fail-with-body']
r=subprocess.run(argv+[f'http://127.0.0.1:{s.server_port}'+path],capture_output=True,text=True,timeout=4)
body=target.read_bytes() if target.exists() else b''
print(f'{label}: exit={r.returncode} http={r.stdout} body_bytes={len(body)}')
return r,body
r,b=run('normal','/ok',True);assert r.returncode==0 and r.stdout=='200' and b==b'complete data\n'
r,b=run('naive_404','/missing');assert r.returncode==0 and r.stdout=='404' and b==b'not found\n'
r,b=run('strict_404','/missing',True);assert r.returncode==22 and r.stdout=='404' and b==b'not found\n'
r,b=run('timeout','/slow',True,'0.1');assert r.returncode==28 and r.stdout=='000'
print('ALL_ASSERTIONS_PASSED')
finally:
s.shutdown();s.server_close();thread.join()
print(subprocess.run(['curl','--version'],capture_output=True,text=True).stdout.splitlines()[0].split(' ')[1])