feat(api): implement rate limiting and error handling

- Added rate limiting for web and API requests.
- Introduced custom error handling for "Too Many Requests" response.
- Created view for "Too Many Requests" error documentation.
This commit is contained in:
Jérémy Munsch 2026-08-12 17:25:45 +02:00
parent f62ea18e75
commit a93fde3a3c
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
9 changed files with 128 additions and 19 deletions

35
bootstrap/exceptions.php Normal file
View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Presentation\Api\Exceptions\ApiExceptionDispatcher;
use Presentation\Web\Exceptions\WebExceptionRenderer;
use Sentry\State\HubInterface;
return function (Exceptions $exceptions): void {
$exceptions->reportable(function (\Throwable $e): void {
if (app()->bound(HubInterface::class)) {
resolve(HubInterface::class)->captureException($e);
}
});
$exceptions->render(function (\Throwable $e, Request $request) {
$apiDispatcher = resolve(ApiExceptionDispatcher::class);
if ($apiResponse = $apiDispatcher->render($e, $request)) {
return $apiResponse;
}
$webRenderer = resolve(WebExceptionRenderer::class);
if ($webResponse = $webRenderer->render($e, $request)) {
return $webResponse;
}
return null;
});
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
};

View file

@ -5,6 +5,14 @@ declare(strict_types=1);
use Illuminate\Foundation\Configuration\Middleware;
return function (Middleware $middleware): void {
$middleware->web(append: [
'throttle:web',
]);
$middleware->api(append: [
'throttle:api.v1',
]);
$middleware->alias([
]);
};

10
config/throttles.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
return [
'web' => (int) env('THROTTLE_WEB', 120),
'api' => [
'v1' => (int) env('THROTTLE_API_V1', 60),
],
];

View file

@ -12,13 +12,29 @@ use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request as IlluminateRequest;
use League\Uri\Http;
use Shared\Pagination;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton('clientIp', function () {
if (app()->environment('local')) {
return request()->getClientIp();
}
return request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp();
});
}
public function boot(): void
{
$this->configureRateLimiting();
ini_set('assert.active', '1');
ini_set('assert.exception', '1');
@ -63,6 +79,18 @@ class AppServiceProvider extends ServiceProvider
});
}
protected function configureRateLimiting(): void
{
RateLimiter::for('web', function (IlluminateRequest $request) {
return Limit::perMinute(Config::integer('throttles.web'))->by(resolve('clientIp'));
});
RateLimiter::for('api.v1', function (IlluminateRequest $request) {
return Limit::perMinute(Config::integer('throttles.api.v1'))->by(resolve('clientIp'));
});
}
public function toLocalInput(CarbonInterface | \DateTimeInterface | int | float | string | null $date): ?string
{
if ($date === null) {

View file

@ -14,6 +14,7 @@ enum ApiErrorCode: string
case NotFound = 'not-found';
case ValidationError = 'validation-error';
case BadRequest = 'bad-request';
case TooManyRequests = 'too-many-requests';
case InternalError = 'internal-error';
public function title(): string
@ -22,6 +23,7 @@ enum ApiErrorCode: string
self::NotFound => 'Resource Not Found',
self::ValidationError => 'Unprocessable Entity',
self::BadRequest => 'Bad Request',
self::TooManyRequests => 'Too Many Requests',
self::InternalError => 'Internal Server Error',
};
}

View file

@ -8,6 +8,7 @@ use Domain\Exceptions\NotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Validation\ValidationException;
use Throwable;
@ -69,6 +70,14 @@ final readonly class ApiExceptionRenderer
],
], 422, ['Content-Type' => self::CONTENT_TYPE]),
$e instanceof ThrottleRequestsException => response()->json([
'type' => (string) $baseUri->withPath('/errors/' . ApiErrorCode::TooManyRequests->value),
'title' => ApiErrorCode::TooManyRequests->title(),
'status' => 429,
'detail' => $e->getMessage(),
'instance' => $request->getPathInfo(),
], 429, array_merge(['Content-Type' => self::CONTENT_TYPE], $e->getHeaders())),
default => response()->json([
'type' => (string) $baseUri->withPath('/errors/' . ApiErrorCode::InternalError->value),
'title' => ApiErrorCode::InternalError->title(),

View file

@ -11,7 +11,15 @@ final class ErrorDocumentationController
{
public function __invoke(ApiErrorCode $code): View
{
return view('errors.docs.' . $code->value, [
$viewName = match ($code) {
ApiErrorCode::NotFound => 'errors.docs.not-found',
ApiErrorCode::ValidationError => 'errors.docs.validation-error',
ApiErrorCode::BadRequest => 'errors.docs.bad-request',
ApiErrorCode::TooManyRequests => 'errors.docs.too-many-requests',
ApiErrorCode::InternalError => 'errors.docs.internal-error',
};
return view($viewName, [
'code' => $code,
'title' => $code->title(),
]);

View file

@ -1,20 +1,20 @@
@extends('layouts.layout')
@section('title', $title)
@push('styles')
<style>
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #1e293b; background: #f8fafc; }
h1 { color: #0f172a; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; }
code { background: #e2e8f0; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
.badge { display: inline-block; padding: 4px 12px; border-radius: 9999px; font-weight: bold; font-size: 0.85em; color: white; }
.badge-404, .badge-400 { background: #ef4444; }
.badge-422 { background: #f59e0b; }
.badge-500 { background: #64748b; }
</style>
@endpush
@section('content')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>@yield('title', 'Error Documentation')</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #1e293b; background: #f8fafc; }
h1 { color: #0f172a; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; }
code { background: #e2e8f0; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
.badge { display: inline-block; padding: 4px 12px; border-radius: 9999px; font-weight: bold; font-size: 0.85em; color: white; }
.badge-404, .badge-400 { background: #ef4444; }
.badge-422 { background: #f59e0b; }
.badge-500 { background: #64748b; }
</style>
@stack('styles')
</head>
<body>
<span class="badge badge-@yield('status', '500')">HTTP @yield('status', '500')</span>
<h1>{{ $title }} ({{ $code->value }})</h1>
@ -22,4 +22,5 @@
<hr style="margin-top: 40px; border: 0; border-top: 1px solid #e2e8f0;">
<p><small>Conforme à la spécification RFC 7807 Problem Details.</small></p>
@endsection
</body>
</html>

View file

@ -0,0 +1,8 @@
@extends('errors.docs.layout')
@section('status', '429')
@section('doc-content')
<p>Trop de requêtes ont été envoyées dans un laps de temps donné. Vous avez dépassé la limite de requêtes autorisée.</p>
<p>Veuillez attendre avant de réessayer.</p>
@endsection