Merge pull request #16986 from grokability/api_throttle_headers
Fixed #16961 - Manually add API headers
This commit is contained in:
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
use JsonException;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
@@ -107,11 +108,10 @@ class Handler extends ExceptionHandler
|
||||
|
||||
$statusCode = $e->getStatusCode();
|
||||
|
||||
// API throttle requests are handled in the RouteServiceProvider configureRateLimiting() method, so we don't need to handle them here
|
||||
switch ($e->getStatusCode()) {
|
||||
case '404':
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode . ' endpoint not found'), 404);
|
||||
case '429':
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Too many requests'), 429);
|
||||
case '405':
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Method not allowed'), 405);
|
||||
default:
|
||||
@@ -201,6 +201,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
|
||||
$this->reportable(function (Throwable $e) {
|
||||
//
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ class Kernel extends HttpKernel
|
||||
'auth:api',
|
||||
\App\Http\Middleware\CheckLocale::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\App\Http\Middleware\SetAPIResponseHeaders::class,
|
||||
],
|
||||
|
||||
'health' => [
|
||||
|
||||
82
app/Http/Middleware/SetAPIResponseHeaders.php
Normal file
82
app/Http/Middleware/SetAPIResponseHeaders.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetAPIResponseHeaders extends ThrottleRequests
|
||||
{
|
||||
|
||||
/**
|
||||
* Add the rate limit headers to the response.
|
||||
*
|
||||
* This extends the original ThrottleRequests middleware to add the 'X-RateLimit-Reset' and 'Retry-After' headers, even
|
||||
* if the rate limit is not exceeded.
|
||||
* @param $maxAttempts
|
||||
* @param $remainingAttempts
|
||||
* @param $retryAfter
|
||||
* @param Response|null $response
|
||||
* @return array|int[]
|
||||
*/
|
||||
protected function getHeaders($maxAttempts, $remainingAttempts, $retryAfter = null, ?Response $response = null)
|
||||
{
|
||||
if ($response &&
|
||||
! is_null($response->headers->get('X-RateLimit-Remaining')) &&
|
||||
(int) $response->headers->get('X-RateLimit-Remaining') <= (int) $remainingAttempts) {
|
||||
$headers = [];
|
||||
$headers['Retry-After'] = $retryAfter; // this is the only line we changed
|
||||
$headers['X-RateLimit-Reset'] = $retryAfter; // this is the only line we changed
|
||||
$headers['X-RateLimit-Reset-Timestamp'] = $this->availableAt($retryAfter); // this is the only line we changed
|
||||
return $headers;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'X-RateLimit-Limit' => $maxAttempts,
|
||||
'X-RateLimit-Remaining' => $remainingAttempts,
|
||||
];
|
||||
|
||||
if (! is_null($retryAfter)) {
|
||||
$headers['Retry-After'] = $retryAfter;
|
||||
$headers['X-RateLimit-Reset'] = $retryAfter; // this is the only line we changed
|
||||
$headers['X-RateLimit-Reset-Timestamp'] = $this->availableAt($retryAfter); // this is the only line we changed
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
protected function handleRequest($request, Closure $next, array $limits)
|
||||
{
|
||||
foreach ($limits as $limit) {
|
||||
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
|
||||
throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
|
||||
}
|
||||
|
||||
$this->limiter->hit($limit->key, $limit->decaySeconds);
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
foreach ($limits as $limit) {
|
||||
$response = $this->addHeaders(
|
||||
$response,
|
||||
$limit->maxAttempts,
|
||||
$this->calculateRemainingAttempts($limit->key, $limit->maxAttempts),
|
||||
$this->getTimeUntilNextRetry($limit->key) // this is the only line we changed
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -76,6 +76,8 @@ class RouteServiceProvider extends ServiceProvider
|
||||
/**
|
||||
* Configure the rate limiters for the application.
|
||||
*
|
||||
* This ONLY fires on 429 responses.
|
||||
*
|
||||
* https://laravel.com/docs/8.x/routing#rate-limiting
|
||||
*
|
||||
* @return void
|
||||
@@ -83,9 +85,17 @@ class RouteServiceProvider extends ServiceProvider
|
||||
protected function configureRateLimiting()
|
||||
{
|
||||
|
||||
// Rate limiter for API calls
|
||||
// Rate limiter for API calls - this sends the correct API headers to show the user the remaining time they have to wait and gives them the 429 status code if they are throttled
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return Limit::perMinute(config('app.api_throttle_per_minute'))->by(optional($request->user())->id ?: $request->ip());
|
||||
return Limit::perMinute(config('app.api_throttle_per_minute'))->by(optional($request->user())->id ?: $request->ip())
|
||||
->response(function ($request, $headers) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'messages' => 'Too many requests. Try again in '.$headers['Retry-After'].' seconds.',
|
||||
'status_code' => 429,
|
||||
'retryAfter' => $headers['Retry-After'] ?? 60,
|
||||
], 429, $headers);
|
||||
});
|
||||
});
|
||||
|
||||
// Rate limiter for forgotten password requests
|
||||
|
||||
42
tests/Feature/ApiRateLimitTest.php
Normal file
42
tests/Feature/ApiRateLimitTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiRateLimitTest extends TestCase
|
||||
{
|
||||
|
||||
public function testRateLimit()
|
||||
{
|
||||
config(['app.api_throttle_per_minute' => 10]);
|
||||
$this->actingAsForApi(User::factory()->create())
|
||||
->getJson(route('api.users.me'))
|
||||
->assertOk()
|
||||
->assertHeader('X-Ratelimit-Limit', config('app.api_throttle_per_minute'))
|
||||
->assertHeader('X-Ratelimit-Remaining', 9);
|
||||
}
|
||||
|
||||
public function testRateLimitDecreasesRemaining()
|
||||
{
|
||||
config(['app.api_throttle_per_minute' => 5]);
|
||||
$expected_remaining = (config('app.api_throttle_per_minute') - 1);
|
||||
$admin = User::factory()->create();
|
||||
|
||||
for ($x = 0; $x < 5; $x++) {
|
||||
|
||||
$this->actingAsForApi($admin)
|
||||
->getJson(route('api.users.me'))
|
||||
->assertOk()
|
||||
->assertHeader('X-Ratelimit-Remaining', $expected_remaining--);
|
||||
|
||||
}
|
||||
|
||||
$this->actingAsForApi($admin)
|
||||
->getJson(route('api.users.me'))
|
||||
->assertStatus(429)
|
||||
->assertHeader('Retry-After', 60);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user