【Laravel】外部APIで発生したエラーをそのままエラーレスポンスとして返す方法


#### 今回の実行環境

```
# php -v
PHP 8.1.4
# php artisan -V
Laravel Framework 9.6.0
```


## 背景
外部のAPIを実行してエラーが発生した際に、404エラーも含めすべて500エラーでレスポンスされ、ログが残ってしまう問題がありました。
そこで外部APIでエラーが発生した場合、そのままのステータスコードでエラーレスポンスを返す方法を調べてみたので紹介します。

## Laravelでのエラーレスポンス
まずLaravelでエラーレスポンスを返す方法として1番は `abort()` ヘルパーを使う方法がスタンダートだと思います。

HTTP例外
https://readouble.com/laravel/9.x/ja/errors.html#http-exceptions

ただこのabort関数、Controller内で使う分には問題ないと思うんですが、ビジネスロジック内で呼び出すのは微妙に使い勝手が悪いです。（メソッド名のニュアンス的にも処理を中断して即時レスポンスを返す的な意味合いが強いため）

今回直そうとしていた外部APIの実行クラスは、いろいろな場所からも呼び出して使えるようにしたかったのでabort関数をあまり使いたくありませんでした。

## Httpクライアントでのエラーについて
今回直そうとしていた外部APIの実行クラスではLaravelのHTTPクライアントを使用していました。
このLaravelのHTTPクライアント、実態はGuzzleなんですがデフォルトと挙動が異なり、API実行時にエラーが発生しても例外が投げられないので自前で処理する必要があります。

なので

- `$response->failed()` など使ってレスポンスのステータスコードを見て自前で処理する
- `$response->throw()` など使って例外が発生するようにする

といった対応をする必要があります。

ただ `$response->throw()` を使った場合、外部APIで発生したエラーはすべて `\Illuminate\Http\Client\RequestException` として例外が投げられます。そうするとLaravelではアプリケーション側のエラーだと判断され、**500エラーとしてレスポンスされます**。そして今回の問題が発生していたという訳です。

実際にLaravelでエラーレスポンスを返している実装がこちらで

/laravel/framework/src/Illuminate/Foundation/Exceptions/Handler.phpから一部抜粋

```php
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

    protected function prepareJsonResponse($request, Throwable $e)
    {
        return new JsonResponse(
            $this->convertExceptionToArray($e),
            $this->isHttpException($e) ? $e->getStatusCode() : 500,
            $this->isHttpException($e) ? $e->getHeaders() : [],
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
        );
    }

    protected function convertExceptionToArray(Throwable $e)
    {
        return config('app.debug') ? [
            'message' => $e->getMessage(),
            'exception' => get_class($e),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace' => collect($e->getTrace())->map(function ($trace) {
                return Arr::except($trace, ['args']);
            })->all(),
        ] : [
            'message' => $this->isHttpException($e) ? $e->getMessage() : 'Server Error',
        ];
    }

    protected function isHttpException(Throwable $e)
    {
        return $e instanceof HttpExceptionInterface;
    }
```

`isHttpException()` で返すレスポンスのステータスコードを決めています。つまりHttpExceptionInterfaceを実装している **HttpException** の例外を投げることで、任意のステータスコードのエラーレスポンスを返すことができるという訳です。

## 外部APIで発生したエラーをそのままエラーレスポンスとして返す方法

外部APIで発生したエラーをそのままのステータスコードでエラーレスポンスとして返す方法は下記の通りです

```php
    try {
        $url = self::BASE_API . '/' . $statusCode;
        $response = Http::get($url)->throw();
        
        return $response->body();
    } catch (RequestException $e) {
        throw new HttpException($e->response->status(), '外部APIでエラーが発生しました。', $e);
    }
```

個人的には `->onError` や `$response->failed()` で処理するよりもtry catchの方が見通しが良いので、`->throw()` を使う方法にしています。（その辺はお好みで）

最終的にはこんな感じになりました。（※検証用に作ったものです）

```php
<?php

namespace App\Http\Client;

use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * 検証用APIクライアント
 */
class HttpstatClient
{
    const BASE_API = 'https://httpstat.us';

    public function getStatus(int $statusCode): ?string
    {
        try {
            $url = self::BASE_API . '/' . $statusCode;
            $response = Http::get($url)->throw();

            return $response->body();
        } catch (RequestException $e) {
            if ($e->response->status() === 404) {
                throw new NotFoundHttpException('データが見つかりませんでした。', $e);
            } else {
                Log::error('外部APIリクエストエラー', [
                    'url'       => $url,
                    'exception' => $e,
                ]);
                throw new HttpException($e->response->status(), '外部APIでエラーが発生しました。', $e);
            }
        }
    }
}
```

404以外の場合はログに残しつつ、APIで発生したエラーをそのままのエラーレスポンスとして返すようにできました。