curl 返回成功就能收文件?用本地 HTTP 验证错误页与超时

定时下载报表时,经常有人把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])

参考

相关推荐
十碗饭吃不饱1 小时前
后端运维:网络联通、进程查询、Java服务启动脚本
java·运维·网络
流浪0011 小时前
计算机网络篇16:传输层——TCP 报头属性字段:16 位窗口大小撑起流量控制,六个标志位标识报文类型
网络协议·tcp/ip·计算机网络·urg紧急指针·tcp报头
apollo_qwe2 小时前
接口超时了,前端为什么不能直接重试?
javascript·http
从琳开始9893 小时前
网络原理入门(二)------OSI七层模型?TCP/IP五层(四层)模型原来是这样的
java·网络·tcp/ip·java-ee·php
Jongkhurun5 小时前
以太网的最小帧长为什么是 64 字节:从 CSMA/CD 冲突窗口推导 64 Byte
网络协议
xiaoye-duck5 小时前
《C++ 项目:手写大模型接入 SDK》ChatGPT接口设计(下):流式响应实现和测试
网络·c++·语言模型·chatgpt
梦帮科技5 小时前
意识启发式量子隧穿退火算法用于全局优化 (CITA) 完整技术指南
网络·人工智能·算法·量子计算·模拟退火算法
坤坤子吖7 小时前
Linux网络编程:UDP进阶,从字典服务器到简单聊天室
linux·服务器·网络·c++·笔记·学习
爱和冰阔落8 小时前
【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server
linux·运维·服务器·网络