背景
Laravelで「外部APIを呼び出し、成功/失敗に応じてDBのステータスカラムを更新する」という、よくある実装をしていました。
class TaskService
{
public function create(array $data): Task
{
$task = Task::create($data);
try {
$this->routineTriggerService->trigger($task);
$task->update(['status' => 'pending']);
} catch (RequestException $e) {
$task->update(['status' => 'error', 'error_message' => $e->getMessage()]);
}
return $task;
}
}
RoutineTriggerService::trigger()の中身はシンプルです。
class RoutineTriggerService
{
public function trigger(Task $task): void
{
Http::withToken(config('services.routine.token'))
->post(config('services.routine.url'), [
'text' => "タスク#{$task->id}: {$task->title}",
])
->throw();
}
}
テストも書いて、成功パターン・失敗パターンともにGreenでした。
public function test_失敗レスポンス時はステータスがerrorになる(): void
{
Http::fake(['*' => Http::response([], 500)]);
// ...
$this->assertSame('error', $task->status);
}
「テストも通ったし、成功でも失敗でもちゃんと動く実装ができた」と思っていました。
何が問題だったか
このコードをレビューに出したところ、「RequestExceptionだけでは足りません」という指摘を受けました。
LaravelのHttpファサードは、外部APIの呼び出し失敗を大きく2種類の例外で表現します。
| 例外 | いつ発生するか |
|---|---|
Illuminate\Http\Client\RequestException |
レスポンスは返ってきたが4xx/5xxだった場合(->throw()が投げる) |
Illuminate\Http\Client\ConnectionException |
そもそも接続できなかった場合(URL不正・タイムアウト・ホスト未起動など) |
一見すると「レスポンスが返るケース」と「そもそも繋がらないケース」で全く別系統の失敗に見えます。実際、RequestExceptionとConnectionExceptionの間に継承関係はありません。
ですが、両者は共通の親クラスを継承しています。
namespace Illuminate\Http\Client;
class ConnectionException extends HttpClientException {}
namespace Illuminate\Http\Client;
class RequestException extends HttpClientException {}
catch (RequestException $e)だけを書いていた私のコードは、ConnectionExceptionを全く捕捉できていませんでした。今回のケースでは外部APIのURLが未設定(空文字)の状態がまさにこれに該当し、タスク作成のたびに接続エラーが発生 → キャッチされずコントローラまで伝播 → タスク作成のAPI自体が500エラーになるという実害のあるバグでした。
テストがGreenだったのは、Http::fake(['*' => Http::response([], 500)])という「レスポンスは返るが失敗する」パターンしか用意していなかったからです。接続そのものが失敗するケースを一度もテストしていなかったので、catch節が狭すぎることに気づけませんでした。
直し方
catchする型を、共通の親クラスに変えるだけです。
use Illuminate\Http\Client\HttpClientException;
try {
$this->routineTriggerService->trigger($task);
$task->update(['status' => 'pending']);
} catch (HttpClientException $e) {
$task->update(['status' => 'error', 'error_message' => $e->getMessage()]);
}
そして、接続失敗のパターンもテストに追加します。Laravelには接続例外をシミュレートする書き方が用意されています。
public function test_接続失敗時もステータスがerrorになる(): void
{
Http::fake(function () {
throw new ConnectionException('Could not connect to the given host.');
});
// ... タスクを作成する
$this->assertSame('error', $task->status);
}
このテストを追加してから実装を直すと、修正前は当然このテストだけRedになります。「レスポンスエラー系のテストはGreenなのに、接続エラー系だけRed」という状態を実際に見て、初めてcatch節の狭さが可視化できました。
まとめ
- Laravelの
Httpファサードの失敗はRequestException(レスポンスあり)とConnectionException(接続不可)の2系統あり、見た目は兄弟クラスのようで実は共通の親HttpClientExceptionを継承している - 「成功でも失敗でも後続処理を続けたい」設計では、
RequestExceptionだけでなくHttpClientExceptionをcatchする -
Http::fake(['*' => Http::response([], 500)])だけでは接続失敗系の分岐はテストできない。Http::fake(fn() => throw new ConnectionException(...))で接続失敗も再現し、両方の分岐をテストで担保する - 今回は実装した本人ではなく、コンテキストを持たない第三者視点のレビューで見つかったバグだった。「自分で書いたコードは自分では見落としに気づきにくい」という当たり前のことを、改めて実感した事例でもある