fix: prevent sensitive field leaks in /me, add retry logic to Brent price sources
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

- Made `/api/auth/me` public and return explicit allowlist (name, email,
  two_factor_confirmed_at, tier, subscription fields) instead of spreading
  `$user->toArray()` which leaked is_admin, stripe_id, pm_type, pm_last_four,
  postcode. Returns `null` when unauthenticated rather than 401.
- Moved `/auth/logout` to remain behind auth:sanctum gate.
- Added 3×200ms retry with exponential backoff to EiaBrentPriceSource and
  FredBrentPriceSource on ConnectionException or 5xx responses. Timeout
  raised from 10s to 30s.
- Both sources now throw typed BrentPriceFetchException on exhausted retries
  instead of silently returning null + logging. Updated tests to assert
  exception message includes HTTP status or "connection failed".
This commit is contained in:
Ovidiu U
2026-05-01 13:22:36 +01:00
parent df70e514e9
commit 73de53994f
6 changed files with 145 additions and 75 deletions

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EiaBrentPriceSource
@@ -14,12 +15,16 @@ final class EiaBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10)
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'api_key' => config('services.eia.api_key'),
'frequency' => 'daily',
@@ -29,32 +34,26 @@ final class EiaBrentPriceSource
'sort[0][direction]' => 'desc',
'length' => 30,
]));
if (! $response->successful()) {
Log::error('EiaBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$rows = collect($response->json('response.data') ?? [])
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
->map(fn (array $row) => [
'date' => $row['period'],
'price_usd' => (float) $row['value'],
])
->all();
if ($rows === []) {
Log::warning('EiaBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('EiaBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
} catch (ConnectionException $e) {
throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('response.data') ?? [])
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
->map(fn (array $row) => [
'date' => $row['period'],
'price_usd' => (float) $row['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class FredBrentPriceSource
@@ -14,12 +15,16 @@ final class FredBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10)
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
@@ -27,32 +32,26 @@ final class FredBrentPriceSource
'limit' => 30,
'file_type' => 'json',
]));
if (! $response->successful()) {
Log::error('FredBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
if ($rows === []) {
Log::warning('FredBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('FredBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
} catch (ConnectionException $e) {
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("FRED returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}