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:
parent
f62ea18e75
commit
a93fde3a3c
9 changed files with 128 additions and 19 deletions
35
bootstrap/exceptions.php
Normal file
35
bootstrap/exceptions.php
Normal 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/*'),
|
||||
);
|
||||
};
|
||||
|
|
@ -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
10
config/throttles.php
Normal 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),
|
||||
],
|
||||
];
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue