Coolify 中的 Laravel HTTP Client 最佳实践:超时、重试、并发请求与测试 Faking 实战
Coolify 是一个基于 Laravel 的自托管 PaaS,其自身大量依赖 Laravel HTTP Client 与 GitHub Releases、BunnyCDN、各云厂商 API、Stripe 等外部服务通信。本文基于仓库内置的规范文档 http-client.md(位于 laravel-best-practices 技能包中,在 SKILL.md 中被列为第 11 条规则),系统讲解五条 HTTP Client 核心实践——显式超时、带退避的重试、显式错误处理、请求池并发、测试 Faking——并结合 Coolify 源码给出可对照的真实调用链,帮助你在自己项目中落地一套可靠的对外 API 调用模式。
一、为什么必须设置显式超时(timeout 与 connectTimeout)
规范文档的第一条规则:默认超时是 30 秒,对大多数 API 调用来说太长,应当总是显式设置 timeout 与 connectTimeout,让故障快速暴露(fail fast)。
反例与正例对照:
// 错误:依赖 30 秒默认超时
$response = Http::get('https://api.example.com/users');
// 正确:显式设置超时
$response = Http::timeout(5)
->connectTimeout(3)
->get('https://api.example.com/users');
文档还给出了一个进阶建议:对特定服务封装 HTTP 宏(macro),把超时、基址、鉴权等参数集中管理:
Http::macro('github', function () {
return Http::baseUrl('https://api.github.com')
->timeout(10)
->connectTimeout(3)
->withToken(config('services.github.token'));
});
$response = Http::github()->get('/repos/laravel/framework');
Coolify 中的真实落地
1. 版本检查场景:短超时 + 快速失败。 UpdateCoolify 负责从 CDN 拉取最新版本号,使用 10 秒超时,且超时失败后会回退到本地缓存的版本号(get_latest_version_of_coolify()),并校验缓存版本不得小于当前运行版本——这是一个「超时 → 降级到缓存 → 版本合法性校验」的完整防御式写法:
$response = Http::retry(3, 1000)->timeout(10)
->get(config('constants.coolify.versions_url'));
其中 versions_url 在 config/constants.php 中定义为:
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
同一模式还出现在 CheckForUpdatesJob、CheckHelperImageJob、PullChangelog 中,全部使用 retry(3, 1000) 组合。
2. 长耗时场景:超时按语义放大。 服务器迁移是典型的长请求,ServerTransferMigrator 将超时显式设为 120 秒,说明超时应根据接口语义(读版本 JSON vs 传输大文件)分别设定,而不是全局一刀切。
3. Webhook 场景:更短的超时。 API 的 webhook 转发在 OtherController 中只给 5 秒:Http::timeout(5)->post($webhook_url, [...]),因为 webhook 是同步附加动作,不能拖慢主请求。
4. 服务级宏:Coolify 的 GitHub / GitLab 客户端。 AppServiceProvider 中注册了两个宏,思路与文档示例一致,但更贴合实际(支持自托管实例、可选 token):
Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) {
if ($github_access_token) {
return Http::withHeaders([
'X-GitHub-Api-Version' => '2022-11-28',
'Accept' => 'application/vnd.github.v3+json',
'Authorization' => "Bearer $github_access_token",
])->baseUrl($api_url);
}
return Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
])->baseUrl($api_url);
});
从源码结构看,Coolify 的宏没有内置 timeout,而是把「连接参数(基址、鉴权头)」与「调用点超时」解耦——超时放在每个具体调用处(如版本检查的 10 秒),这样同一服务在不同场景下可以有不同的超时策略。
二、用 retry() + 退避应对外部 API 的瞬时故障
规范文档第二条:外部 API 存在瞬时故障,应使用 retry() 配递增延迟,而不是让第一次失败直接抛错:
// 错误:一次失败即抛错
$response = Http::post('https://api.stripe.com/v1/charges', $data);
if ($response->failed()) {
throw new PaymentFailedException('Charge failed');
}
// 正确:退避重试
$response = Http::retry([100, 500, 1000])
->timeout(10)
->post('https://api.stripe.com/v1/charges', $data);
文档同时强调只应对特定错误重试,并给出带异常判定回调的写法:
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data');
也就是说:连接异常(ConnectionException)和 5xx(serverError())值得重试;4xx 客户端错误重试毫无意义,只会放大压力。
Coolify 的重试模式
Coolify 中检索不到对 Stripe 的裸 Http::post(支付走官方 Stripe SDK),但版本检查、变更日志、模板拉取等「读外部数据」场景统一采用 retry(3, 1000)(3 次重试、固定 1000ms 延迟):
- CheckForUpdatesJob:
Http::retry(3, 1000)->get(config('constants.coolify.versions_url')) - Init:
Http::retry(3, 1000, throw: false)—— 通过throw: false关闭抛异常,转而手动检查响应状态,适合「失败可容忍」的启动流程 - PullTemplatesFromCDN:同样是
retry(3, 1000, throw: false)模式
这些场景的共同特点是:请求方是队列 Job 或 artisan 命令,本身具备失败重跑能力,HTTP 层的 3 次退避重试相当于在任务粒度之上再加一层细粒度容错。
三、显式处理 4xx/5xx:throw() 与优雅降级
规范文档第三条:Laravel HTTP Client 默认不会因 4xx/5xx 抛异常,必须检查状态码或显式调用 throw():
// 错误:直接解析,可能是错误响应体
$response = Http::get('https://api.example.com/users/1');
$user = $response->json();
// 正确:throw() 显式抛错
$response = Http::timeout(5)
->get('https://api.example.com/users/1')
->throw();
$user = $response->json();
文档还给出「优雅降级」版本,按状态分支处理,把 404 语义化为 null:
$response = Http::get('https://api.example.com/users/1');
if ($response->successful()) {
return $response->json();
}
if ($response->notFound()) {
return null;
}
$response->throw();
Coolify 中的「成功/失败」分支写法
UpdateCoolify 是这一模式的典型实现:不依赖 throw(),而是先判断 $response->successful(),成功则解析 JSON;失败或抛异常时降级到缓存版本,并用 version_compare() 校验缓存版本不能落后于当前运行版本,避免基于脏数据做出错误的自动更新决策。日志上区分了 Log::error(缓存也损坏、不可恢复)与 Log::warning(CDN 不可用但缓存可用)两个级别,便于告警分级。
这个例子印证了文档的意图:successful() / notFound() / throw() 三件套应组合使用,让「哪些状态是业务内预期、哪些必须上抛」显式地写在调用点。
四、用 Http::pool() 并发多个独立请求
规范文档第四条:多个相互独立的 API 调用应使用 Http::pool() 并发执行,而非串行等待:
// 错误:串行三次往返
$users = Http::get('https://api.example.com/users')->json();
$posts = Http::get('https://api.example.com/posts')->json();
$comments = Http::get('https://api.example.com/comments')->json();
// 正确:并发池
use Illuminate\Http\Client\Pool;
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('users')->get('https://api.example.com/users'),
$pool->as('posts')->get('https://api.example.com/posts'),
$pool->as('comments')->get('https://api.example.com/comments'),
]);
$users = $responses['users']->json();
$posts = $responses['posts']->json();
Coolify 的真实用例:BunnyCDN 批量上传与缓存清除
SyncBunny 在把发布产物同步到 BunnyCDN 时,连续使用了两个 pool:
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: "$compose_file_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file"),
$pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"),
$pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"),
$pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"),
$pool->storage(fileName: "$upgrade_postgres_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_postgres_script"),
$pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"),
]);
Http::pool(fn (Pool $pool) => [
$pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"),
// ...其余 purge
]);
两个细节值得注意:其一,六个文件上传在同一个 pool 中并发完成,显著缩短了发布同步耗时;其二,上传与 purge(缓存清除)被拆成两个串行执行的 pool——上传必须全部成功后才清除 CDN 缓存,顺序不能颠倒,这正是「用 pool 并发、用顺序控制一致性」的工程化用法。该文件内还有单请求超时设置(第 55 行 的 Http::timeout(30) 与 第 351 行 的 Http::timeout(10)),与 pool 组合使用。
五、测试中 Fake 所有 HTTP 调用
规范文档第五条:测试中绝不允许真实 HTTP 请求,应使用 Http::fake() 配合 preventStrayRequests():
it('syncs user from API', function () {
Http::preventStrayRequests(); // 拦截任何未被 fake 的真实请求
Http::fake([
'api.example.com/users/1' => Http::response([
'name' => 'John Doe',
'email' => 'john@example.com',
]),
]);
$service = new UserSyncService;
$service->sync(1);
Http::assertSent(function (Request $request) {
return $request->url() === 'https://api.example.com/users/1';
});
});
文档最后还提醒要覆盖失败场景:
Http::fake([
'api.example.com/*' => Http::failedConnection(),
]);
Coolify 的测试实践
PullChangelogTest 完整演示了这套模式:先通过 config() 重写 releases_url 指向测试域名,再用 Http::fake() 按 URL 精确映射伪造响应,最后用 Http::assertSent() 断言确实发出了请求并落盘了 changelog 文件:
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
Http::fake([
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
]);
(new PullChangelog)->handle();
Http::assertSent(fn ($request) => $request->url() === 'https://example.test/releases.json');
该文件还通过伪造 payload 验证了业务分支(跳过 draft 为 true 的 release)。在整个 tests/Feature 目录下,Http::fake 被广泛使用(如 SyncBunnyTest、VultrApiTest、DigitalOceanApiTest 等 30+ 测试文件),是 Coolify 保证 CI 不依赖外部网络的统一手段。这也呼应了 SKILL.md 中第 8 条测试规则里「使用各类 fake」的总体原则。
六、小结:五条规则与 Coolify 源码对照表
| 规范要点 | 规范出处 | Coolify 佐证 |
|---|---|---|
显式 timeout / connectTimeout |
文档 §1 | UpdateCoolify、ServerTransferMigrator(120s 长请求)、OtherController(5s webhook) |
| 服务级客户端宏 | 文档 §1 | AppServiceProvider 的 GitHub / GitLab 宏 |
retry() + 退避、仅重试瞬时错误 |
文档 §2 | CheckForUpdatesJob、Init 的 retry(3, 1000) |
throw() / 状态码显式分支 |
文档 §3 | UpdateCoolify 的「成功解析 → 失败降级到缓存 → 版本校验」 |
Http::pool() 并发 |
文档 §4 | SyncBunny 批量上传 + 批量 purge |
测试 Http::fake() + assertSent() |
文档 §5 | PullChangelogTest 等 30+ 测试文件 |
落地建议:在 Coolify 这类与多个外部服务(版本 CDN、CDN、云厂商、Git 平台)交互的 Laravel 应用中,HTTP 调用的可靠性不靠单一技巧,而是「超时定边界、重试扛抖动、状态分支管降级、pool 提吞吐、fake 保测试」五层叠加。编写新调用点时,先查同目录兄弟文件的既有模式(这正是 SKILL.md 中 Consistency First 原则的要求),再按本文的规则补全缺失的防御层。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00