PHPで外部APIのフォールバックとキャッシュを実装する:該当なしと通信失敗を分けて扱う
作成日:2026.10.23
外部APIの「該当なし」と通信失敗を区別し、代替APIへ切り替える条件と取得結果のキャッシュをPHPで実装します。架空の商品情報APIを使い、問い合わせ回数・待ち時間の制限、キャッシュの有効期限・無効化、モックによる動作確認を解説します。
目次
外部APIからデータを取得するとき、「データ該当なし」と障害を分けた上で、代替APIへ切り替えるフォールバックと、取得結果のキャッシュをPHPで実装してみます。通信の基本はPHPのcURLによる外部API連携の記事、APIごとの結果をそろえる考え方は書誌情報を取得する記事で扱っています。その先の「どの結果を採用するか」が今回の主題です。
まずは、該当なしと取得失敗を分ける
フォールバックは、最初の取得先で必要な結果を得られなかったときに、別の取得先を使うことです。同じAPIへもう一度問い合わせる再試行とは分けて考えます。
取得結果をすべてnullにしてしまうと、呼び出し側は次にどうすればよいか判断できません。少なくとも、次のように分けておくと扱いやすくなります。
| 状態 | 例 | 判断すること |
|---|---|---|
| 成功 | 対象IDと必要な項目を取得できた | その結果を採用できるか |
| 該当なし | 正常な検索結果が0件 | 別の収録範囲を持つAPIも探すか |
| 通信失敗 | タイムアウト、接続失敗 | 一時的な障害か、接続設定の問題か |
| HTTPエラー | 401、429、503など | 認証、利用制限、一時障害のどれか |
| 解析・内容の異常 | JSONが壊れている、要求と別のIDが返る | 仕様変更や実装の誤りがないか |
HTTP 404を、そのまま「商品が存在しない」と判断できるとは限りません。URLの誤りでも404は返ります。該当なしの判定には、使用するAPIの仕様が必要です。
今回のサンプルで決めておくこと
架空の商品情報APIを2つ使う想定にします。どちらも共通の商品IDで検索でき、同じ言語の公開商品名を返すものとします。価格・在庫や、利用者ごとに異なるデータは扱いません。
- 確認環境はWindows、64ビット版PHP 8.3、PowerShell
- 問い合わせ順は
primary、secondary - 成功時はHTTP 200と
{"item":{"id":"P001","name":"サンプル商品"}} - 該当なしはHTTP 200と
{"item":null} - 成功結果を60秒キャッシュし、期限切れの結果は使わない
- 同じAPIへの再試行はせず、各APIを最大1回、合計2回まで呼び出す
- 通信の待ち時間は1回最大300ミリ秒、処理全体の予算は1秒とする
時間や保存期間は、処理の流れを説明するための例です。実際のAPIに適した推奨値ではありません。特に300ミリ秒は、外部サービスによっては短すぎます。
代替APIが同じ商品IDを使っていても、版、地域、更新時点、必須項目が違う場合があります。「成功したから何でも採用する」とせず、置き換えてよいデータの範囲を先に決めておきます。今回はIDが一致し、空でない商品名を持つことを採用条件にします。
切り替え条件
| 結果 | 今回の処理 |
|---|---|
| 成功 | 採用してキャッシュする |
| 該当なし | 次のAPIへ進む |
| タイムアウト、接続失敗、ホスト名の名前解決失敗 | 次のAPIへ進む。原因は記録する |
| HTTP 502・503・504 | 次のAPIへ進む |
| 401・403・429、その他のHTTPエラー | 中止する |
| TLS検証失敗、解析エラー、IDや必須項目の異常 | 中止する |
ホスト名の名前解決失敗も設定ミスの可能性がありますし、502・503・504だから必ず一時障害というわけでもありません。ここでは代替先を1回だけ試す方針とし、原因の調査に使える記録を残します。
429で別APIへ進まないのは、このサンプルの運用方針です。利用制限を切り替えで回避する前提にはせず、まず契約や制限の単位を確認します。401・403や壊れたレスポンスも、そのまま中止して設定・仕様を確認します。
レスポンスを共通の状態へ変換する
まず、通信結果をfound、not_found、temporary、stopへ変換します。以下のPHPコードは、掲載順に同じfallback-demo.phpへ保存してください。冒頭の<?phpは最初の1回だけ必要です。
<?php
declare(strict_types=1);
function classifyResponse(array $response, string $id): array
{
$error = $response['error'];
if ($error !== 0) {
$temporary = in_array($error, [
CURLE_OPERATION_TIMEDOUT,
CURLE_COULDNT_CONNECT,
CURLE_COULDNT_RESOLVE_HOST,
], true);
return [
'state' => $temporary ? 'temporary' : 'stop',
'reason' => 'curl_' . $error,
];
}
$status = $response['status'];
if (in_array($status, [502, 503, 504], true)) {
return ['state' => 'temporary', 'reason' => 'http_' . $status];
}
if ($status !== 200) {
return ['state' => 'stop', 'reason' => 'http_' . $status];
}
try {
$data = json_decode($response['body'], true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return ['state' => 'stop', 'reason' => 'invalid_json'];
}
if (!is_array($data) || !array_key_exists('item', $data)) {
return ['state' => 'stop', 'reason' => 'invalid_schema'];
}
if ($data['item'] === null) {
return ['state' => 'not_found', 'reason' => 'empty_result'];
}
$item = $data['item'];
if (!is_array($item)
|| ($item['id'] ?? null) !== $id
|| !is_string($item['name'] ?? null)
|| trim($item['name']) === '') {
return ['state' => 'stop', 'reason' => 'invalid_item'];
}
return [
'state' => 'found',
'reason' => 'ok',
'item' => ['id' => $id, 'name' => $item['name']],
];
}
JSONとして読めることと、目的のデータであることは別です。たとえばP001を要求してP002が返った場合は、成功として扱いません。別APIへ置き換えるときにも、この確認が必要になります。
キャッシュを確認し、順番に問い合わせる
次に、問い合わせ順とキャッシュを扱う関数を作ります。通信処理は$fetchという関数として受け取る形にします。これで、同じ制御処理へモックとcURLのどちらも渡せます。
function findItem(
string $id,
callable $fetch,
array &$cache,
int $budgetMs = 1000
): array {
if (!preg_match('/\A[A-Z0-9-]{1,32}\z/', $id) || $budgetMs < 1) {
throw new InvalidArgumentException('Invalid ID or time budget.');
}
$key = 'catalog:v1:ja:' . $id;
if (isset($cache[$key]) && $cache[$key]['expires_at'] > time()) {
return ['state' => 'found', 'cache' => 'hit', 'trace' => []]
+ $cache[$key];
}
unset($cache[$key]);
$deadline = hrtime(true) + $budgetMs * 1_000_000;
$trace = [];
$hadFailure = false;
foreach (['primary', 'secondary'] as $source) {
$remainingMs = (int) floor(($deadline - hrtime(true)) / 1_000_000);
if ($remainingMs < 1) {
return ['state' => 'unavailable', 'reason' => 'deadline', 'trace' => $trace];
}
$response = $fetch($source, $id, min(300, $remainingMs));
if (hrtime(true) >= $deadline) {
$trace[] = ['source' => $source, 'state' => 'stop', 'reason' => 'deadline'];
return ['state' => 'unavailable', 'reason' => 'deadline', 'trace' => $trace];
}
$result = classifyResponse($response, $id);
$trace[] = [
'source' => $source,
'state' => $result['state'],
'reason' => $result['reason'],
];
if ($result['state'] === 'found') {
$fetchedAt = time();
$cache[$key] = [
'item' => $result['item'],
'source' => $source,
'fetched_at' => $fetchedAt,
'expires_at' => $fetchedAt + 60,
];
return ['state' => 'found', 'cache' => 'miss', 'trace' => $trace]
+ $cache[$key];
}
if ($result['state'] === 'stop') {
return ['state' => 'unavailable', 'reason' => $result['reason'], 'trace' => $trace];
}
if ($result['state'] === 'temporary') {
$hadFailure = true;
}
}
return [
'state' => $hadFailure ? 'unavailable' : 'not_found',
'reason' => $hadFailure ? 'incomplete_search' : 'all_not_found',
'trace' => $trace,
];
}
ポイントは、両方のAPIで該当なしを確認できた場合だけ、最終結果をnot_foundにすることです。片方がタイムアウトし、もう片方が該当なしなら、検索しきれていないのでunavailableにします。
キャッシュには商品だけでなく、取得元と取得時刻も保存します。キャッシュから返す場合も元の取得元を維持し、新しく取得したようには見せません。secondaryの結果をキャッシュした後は、期限までprimaryへ問い合わせない点にも注意してください。
経過時間の計測には、時刻の調整で値が前後しないhrtime()を使っています。キャッシュの取得時刻・有効期限は、記録しやすいUNIXタイムスタンプで保持しています。
モックでフォールバックを動かす
最初のAPIはタイムアウトし、次のAPIは成功するモックを渡してみます。これは通信結果を模擬する関数で、実際のネットワーク接続や待機は行いません。cURLの定数を使うため、実行にはcURL拡張が必要です。
$calls = [];
$mock = function (string $source, string $id, int $timeoutMs) use (&$calls): array {
$calls[] = $source;
if ($source === 'primary') {
return ['error' => CURLE_OPERATION_TIMEDOUT, 'status' => 0, 'body' => ''];
}
return [
'error' => 0,
'status' => 200,
'body' => json_encode([
'item' => ['id' => $id, 'name' => 'サンプル商品'],
], JSON_THROW_ON_ERROR),
];
};
$cache = [];
$first = findItem('P001', $mock, $cache);
$second = findItem('P001', $mock, $cache);
echo $first['state'], ' / ', $first['source'], ' / ', $first['cache'], PHP_EOL;
echo $second['state'], ' / ', $second['source'], ' / ', $second['cache'], PHP_EOL;
echo implode(' -> ', $calls), PHP_EOL;
PowerShellで実行します。
php fallback-demo.php
実行結果は次の通りです。
found / secondary / miss
found / secondary / hit
primary -> secondary
1回目はprimaryからsecondaryへ切り替わり、2回目はキャッシュを使っています。2回目の取得では、どちらのAPIも呼ばれていません。
期限切れ、更新、削除を確認する
期限切れを試す場合は、同じファイルの末尾へ次を追加します。60秒待たずに期限切れの状態を作れます。
$cache['catalog:v1:ja:P001']['expires_at'] = time() - 1;
$third = findItem('P001', $mock, $cache);
echo $third['cache'], PHP_EOL;
echo implode(' -> ', $calls), PHP_EOL;
missとなり、呼び出し履歴はprimary -> secondary -> primary -> secondaryに増えます。期限切れの結果は捨ててから再取得しています。再取得に失敗しても、古い結果へ戻す処理は入れていません。
元データの更新や削除を把握した場合も、同じキーを無効化します。
unset($cache['catalog:v1:ja:P001']);
次回はAPIへ問い合わせ直すため、更新後の商品名や該当なしを反映できます。ただし、削除後も代替APIへ古い商品が残っていれば、再びその商品を採用する可能性があります。削除通知が商品全体の利用停止を意味するなら、キャッシュ削除に加えて、停止対象IDを記録して取得・表示自体を止める必要があります。
この例のキャッシュはPHPの配列なので、プロセスが終了すると消えます。通常のWebリクエストをまたいで共有する場合は、Redisなどの共有ストアへ置き換えます。その場合はキーの削除も全サーバーへ反映し、保存失敗時の扱い、同時更新、同じデータへの問い合わせ集中も検討してください。
実際の通信へ差し替える場合
通信処理は、以下のようなcURL関数に置き換えられます。URLは架空のもので、そのまま実行して成功する例ではありません。実際に使用するときは、コード内で管理する接続先へ変更してください。
function fetchWithCurl(string $source, string $id, int $timeoutMs): array
{
$bases = [
'primary' => 'https://primary.example.com/items/',
'secondary' => 'https://secondary.example.com/items/',
];
if (!isset($bases[$source])) {
throw new InvalidArgumentException('Unknown source.');
}
$handle = curl_init($bases[$source] . rawurlencode($id));
if ($handle === false) {
throw new RuntimeException('Failed to initialize cURL.');
}
try {
if (!curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_FOLLOWLOCATION => false,
])) {
throw new RuntimeException('Failed to configure cURL.');
}
$body = curl_exec($handle);
return [
'error' => curl_errno($handle),
'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
'body' => $body === false ? '' : $body,
];
} finally {
curl_close($handle);
}
}
// 実際の接続先を設定してから使う。
// $result = findItem('P001', 'fetchWithCurl', $cache);
接続先をコード内の一覧に限定し、利用者が任意のURLを指定する形にはしていません。TLS証明書の検証も無効にしません。リダイレクトは追わず、今回の分類では3xxも中止になります。
cURLのタイムアウト設定には、接続までの制限と転送全体の制限があります。今回は残り時間を両方へ渡しています。ただし、ホスト名の名前解決などの制限精度はlibcurlのビルドや環境にも左右されます。全体の1秒は処理予算であり、必ず1秒以内にPHPの処理が終了するという保証ではありません。戻ってきた時点でも期限を確認し、予算を過ぎた結果は採用しない形にしています。
実APIへ差し替える際は、レスポンス形式に合わせてclassifyResponse()も変更します。また、この通信例はレスポンスサイズの制限や認証、HTTPキャッシュヘッダーの解析までは実装していません。実データを保存する前に、利用条件と保存を許可する情報を確認してください。
再試行とキャッシュで気を付けること
再試行を何重にも入れない
今回は、通信関数の中でも制御関数の中でも再試行しません。2つのAPIにそれぞれ3回ずつ試すだけでも、待ち時間や呼び出し数が増えてしまいます。再試行を入れるなら、担当する場所を1か所に決め、全体の回数と処理時間を管理します。
Retry-Afterがある場合は、次のリクエストまで待つ時間も確認します。値は秒数の場合とHTTP日時の場合があります。今回のような短い画面リクエスト内で長く待つより、利用者に再実行を案内するか、バックグラウンド処理で待機・再試行する方が適している場合もあります。今回のコードはヘッダーを解析せず、同じAPIへは再試行しません。継続的に呼び出すシステムでは、取得先ごとの待機期限を共有するなど、後続リクエストからのアクセスも制御します。仕様はRFC 9110のRetry-Afterを参照してください。
なお、今回は取得用のGETを想定しています。注文や決済などの更新処理へ、そのまま同じ再試行・切り替えを適用しないでください。通信が失敗しても相手側では処理済みかもしれないため、重複実行を防ぐ設計が必要です。再試行と冪等性についてはHTTPの冪等なメソッドに関する仕様も参考になります。
障害を「該当なし」としてキャッシュしない
今回保存するのは成功結果だけです。該当なしを短時間保存する方法もありますが、登録直後のデータが見つからない期間が生じるので、成功結果と別の有効期限を検討します。通信失敗まで該当なしとして保存するのは避けましょう。
キーには、データ形式の版と言語を含めています。実際には検索条件、地域、利用者や権限による違いも必要に応じて含めます。利用者ごとの情報を公開データと同じキーで共有しないようにします。
60秒の有効期限だけでは、元データの更新・削除を即座には検知できません。通知を受けて無効化する、定期的に確認する、そもそもキャッシュしない、といった方法を要件に合わせて選びます。障害時に古いキャッシュを使う場合も、許容する古さと表示方法を別途決める必要があります。
このサンプルは、保存が許可された架空のデータを扱うアプリケーション内キャッシュです。HTTPキャッシュを一式実装したものではありません。実APIでは、利用規約に加えてCache-Controlなどの指示も確認し、保存禁止のデータを一律に60秒保存する、といった使い方をしないようにします。HTTPキャッシュの指示についてはRFC 9111のCache-Controlを参照してください。
動作確認の観点
モックの返す値を変えると、障害の発生を待たずに切り替え条件を確認できます。特に次の組み合わせを確認しておくとよいかと思います。
| primary | secondary | 期待する結果 |
|---|---|---|
| 成功 | 呼ばない | found、primaryを保存 |
| 該当なし | 成功 | found、secondaryを保存 |
| タイムアウトまたは503 | 成功 | found、失敗理由もtraceに残る |
| 該当なし | 該当なし | not_found、保存しない |
| タイムアウト | 該当なし | unavailable、保存しない |
| 通信失敗 | 通信失敗 | unavailable、保存しない |
| 401・403・429、TLSエラー、壊れたJSON、別ID | 呼ばない | unavailable、原因を調べる |
| 全体の時間予算を超過 | 呼ばない | unavailable、遅れて返った結果も保存しない |
このほか、有効なキャッシュでは問い合わせが0回になること、期限切れや明示的な無効化の後は再取得すること、別IDのキャッシュが混ざらないことも確認します。モックの分岐確認と、実際の通信・サービス利用条件の確認は別です。この記事の架空APIへ接続した実績を示すものではありません。
掲載コードはPHP 8.3で構文と実行結果を確認しています。モックによる状態の組み合わせに加え、cURL関数の接続先をローカルの検証サーバーへ差し替え、HTTP 200、503、転送のタイムアウトを確認しました。外部の実サービスとの接続や、本番環境での負荷検証は行っていません。
traceには取得元と状態、エラー番号だけを残しています。本番環境でログへ出す場合も、APIキーやレスポンス本文、個人情報をそのまま記録せず、画面には利用者向けの案内を返すようにします。
まとめ
フォールバックを入れるときは、まず該当なしと取得失敗を分け、どの状態なら次のAPIへ進むかを決めます。全部の取得先で該当なしを確認できた場合と、障害で確認できなかった場合も区別しておくと、誤った案内を防ぎやすくなります。
キャッシュは取得元・取得時刻・有効期限を持たせ、更新・削除と利用条件まで含めて設計します。切り替えや再試行を増やす前に、問い合わせ回数と待ち時間の上限を決めておくと、処理の見通しがよくなるかと思います。
奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。
これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。
【制作会社・企業様向けサポート】
Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。
ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。
Laravelのコードレビューで遅いSQLの候補を洗い出す
2026.10.02
Laravelのコードレビューで、ループ内のDBアクセスやEloquentのN+1、大量取得、検索条件とインデックスなど、性能問題につながりやすい箇所を候補として洗い出します。Laravelで発行SQLと実行時間を確認し、MySQLのEXPLAINや実データに近い環境での計測へつなげる方法を紹介します。コードレビューだけではSQLの実行時間を断定できない点も説明します。
Windows 11でDocker Composeを使ってLaravel環境を作る:Container・Volume・Networkの基本
2026.10.01
Windows 11のDocker Desktop上に、DockerfileとDocker Composeを使ってLaravel・PHP・MySQL・Mailpitの開発環境を構築します。ImageとContainerの関係、Bind MountとNamed Volumeによるデータの保存、ポート公開とContainer間通信の違いを実例で確認。LaravelからMySQLへ接続するときに、localhostではなくService名を使う理由も説明します。
PHPでYAML設定を検証する:symfony/yamlとopis/json-schemaの使い方
2026.09.30
symfony/yamlでYAMLを読み込み、opis/json-schemaで必須項目や型、許可する値を検証する方法を紹介します。YAMLのマップをstdClassとして扱う方法や、空のobjectとarrayの違い、信頼できないYAMLを扱う際の注意点も解説します。
PHPの正規表現で日本語・改行を扱う:u・m・s修飾子の違い
2026.09.29
PHPの正規表現で日本語や複数行テキストを扱う際の、u・m・s修飾子の違いを解説します。Unicode文字クラスと全角数字、CRLFの行末処理、\A・\zの使い方、Unicode正規化との違いを、PHP 8.3.15・PCRE2 10.42で確認したコード例とともに紹介します。