外部API呼び出しでタイムアウトが頻発するPHPアプリの原因を体系的に切り分け、Laravelで使える実践的な再試行ロジックの実装方法を解説します。
「また503エラーだ…」その原因、ちゃんと切り分けられていますか?
こんな悩みを抱えていませんか?
- 決済APIや配送APIを呼び出すと、ときどきタイムアウトが発生してユーザーにエラーが届く
- ログを見てもどこで止まっているのかよくわからない
- 再試行の処理を書いたつもりだが、無限ループになりかけて怖い
- 外部サービス側の問題なのか、自分のサーバー側の問題なのか判断できない
外部API連携のタイムアウト問題は、原因が複数の層にまたがるため、やみくもに設定値を変えても解決しないことが多いです。あるECサイトのクライアント様では、配送会社のAPIが月に数回タイムアウトするたびに注文処理が止まり、カスタマーサポートへの問い合わせが急増するという状況が続いていました。「外部サービスのせいだから仕方ない」と諦めていたそうですが、適切な再試行ロジックとタイムアウト設計を導入したことで、ユーザー向けエラー発生率を約90%削減できました。
この記事では、タイムアウトの原因を体系的に切り分ける方法と、LaravelでもシンプルなPHPでも使える実践的な再試行ロジックの実装を解説します。
タイムアウトはなぜ起きるのか?原因の「層」を理解する
タイムアウトの原因は大きく4つの層に分類できます。ここを整理せずに対処すると、的外れな修正に時間を浪費します。
flowchart TD
A[タイムアウト発生] --> B{どの層の問題?}
B --> C[① PHPの設定層\nmax_execution_time / default_socket_timeout]
B --> D[② HTTPクライアント層\ncURL / Guzzleの設定]
B --> E[③ ネットワーク層\nDNS解決 / 接続確立 / SSL]
B --> F[④ 外部API層\n相手サーバーの応答遅延]
C --> G[php.iniやset_time_limit()で対応]
D --> H[connect_timeout / timeoutを適切に設定]
E --> I[pingやtracerouteで疎通確認]
F --> J[再試行ロジックとサーキットブレーカーで対応]① PHPの設定層
php.ini の max_execution_time(デフォルト30秒)に引っかかっているケースです。特にCLIやキュー処理では set_time_limit(0) を明示的にセットしていないと、長時間の処理でスクリプト自体が強制終了します。
② HTTPクライアント層
GuzzleやcURLで timeout と connect_timeout を区別せずに設定しているケースが多いです。connect_timeout は接続確立までの待機時間、timeout はレスポンス受信完了までの総時間です。この2つは別々に設定する必要があります。
③ ネットワーク層
DNS解決の遅延や、ファイアウォール・NATの設定によってパケットがドロップされているケースです。特にクラウド環境(AWS / GCPなど)ではセキュリティグループやVPCの設定ミスが原因になることがあります。
④ 外部API層
相手サービスの負荷やメンテナンスによる遅延です。この層の問題に対しては、こちら側のコードで再試行とフォールバックを実装するのが唯一の現実的な対応策になります。
まず原因を切り分ける:ログとデバッグの手順
「なんとなくタイムアウトしている」状態のまま再試行ロジックを書くのは禁物です。問題の層を特定してから実装に入ることが重要です。
Guzzleで詳細ログを取る
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
$stack = HandlerStack::create();
// リクエスト/レスポンスのタイミングを記録するミドルウェア
$stack->push(Middleware::tap(
function (RequestInterface $request) {
logger()->info('API Request', [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'time' => microtime(true),
]);
},
function (RequestInterface $request, array $options, $response) {
logger()->info('API Response', [
'status' => $response instanceof ResponseInterface ? $response->getStatusCode() : 'error',
'time' => microtime(true),
]);
}
));
$client = new Client([
'handler' => $stack,
'connect_timeout' => 5, // 接続確立のタイムアウト(秒)
'timeout' => 15, // レスポンス受信のタイムアウト(秒)
]);
このログで「接続直後に落ちているのか」「レスポンス受信中に落ちているのか」が判別できます。接続直後ならネットワーク層、レスポンス受信中なら外部API層の問題である可能性が高いです。
実践:再試行ロジックの実装
原因が「外部API層」と特定できたら、次はExponential Backoff(指数バックオフ)付きの再試行ロジックを実装します。単純なリトライはサーバーに負荷をかけて状況を悪化させるため、リトライ間隔を徐々に広げるのがベストプラクティスです。
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class ApiClient
{
private Client $client;
private int $maxRetries = 3;
public function __construct()
{
$stack = HandlerStack::create();
$stack->push($this->retryMiddleware());
$this->client = new Client([
'handler' => $stack,
'base_uri' => config('services.external_api.base_url'),
'connect_timeout' => 5,
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . config('services.external_api.token'),
'Accept' => 'application/json',
],
]);
}
private function retryMiddleware(): callable
{
return Middleware::retry(
// リトライ判定: 何回目か、リクエスト、レスポンス、例外を受け取る
function (int $retries, RequestInterface $request, ?ResponseInterface $response, ?\Throwable $exception) {
// 最大リトライ回数を超えたら諦める
if ($retries >= $this->maxRetries) {
logger()->error('API最大リトライ到達', [
'uri' => (string) $request->getUri(),
'retries' => $retries,
]);
return false;
}
// 接続タイムアウトはリトライ対象
if ($exception instanceof ConnectException) {
logger()->warning('APIタイムアウト、リトライします', ['attempt' => $retries + 1]);
return true;
}
// 429(レート制限) / 503(一時的なサービス停止) もリトライ対象
if ($response && in_array($response->getStatusCode(), [429, 503])) {
logger()->warning('API一時エラー、リトライします', [
'status' => $response->getStatusCode(),
'attempt' => $retries + 1,
]);
return true;
}
return false;
},
// 待機時間: 指数バックオフ(1秒 → 2秒 → 4秒)
function (int $retries): int {
return (int) (1000 * pow(2, $retries - 1)); // ミリ秒
}
);
}
public function fetchOrder(string $orderId): array
{
$response = $this->client->get("/orders/{$orderId}");
return json_decode($response->getBody()->getContents(), true);
}
}
よくある失敗パターンと対処法
実案件でよく見かける「やりがちなミス」を紹介します。これを知っているかどうかで、デバッグ時間が大きく変わります。
```php
// Jitter付きバックオフ
function (int $retries): int {
return (int) (1000 * pow(2, $retries - 1)) + rand(0, 500);
}
```
開発・運用でお困りなら
システム開発
設計から運用まで、堅牢なシステムを構築します
※ 通常1営業日以内にご返信します
まとめと次のステップ
タイムアウト問題の解決は「とりあえずリトライを増やす」ではなく、原因の層を特定してから適切な対策を打つことが出発点です。今回の内容を整理すると次のようになります。
対策を段階的に積み重ねることで、ユーザー体験は確実に改善されます。まず取り組むべきアクションをチェックリストとして整理しましたので、ぜひ手元の環境と照らし合わせてみてください。
「コードは書けるが、設計の方針で迷っている」「既存のAPI連携コードを見直したいが、どこから手をつければいいかわからない」という場合は、Fivenine Designにお気軽にご相談ください。神奈川を拠点に20年以上の実績を持つ弊社が、コードレビューから設計改善まで一緒に取り組みます。