Skip to content

Commit

Permalink
feat: throttle request middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
storyn26383 committed Oct 25, 2023
1 parent 08e67bf commit 1d8a5ea
Show file tree
Hide file tree
Showing 4 changed files with 373 additions and 2 deletions.
8 changes: 6 additions & 2 deletions src/router/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@
},
"require": {
"php": "^8.1",
"hyperf/http-server": "^3.1",
"hyperf/context": "^3.1"
"hyperf/context": "^3.1",
"hyperf/http-server": "^3.1"
},
"suggest": {
"hyperf/http-message": "Required to use throttle requests middleware. (^3.1)",
"swooletw/hyperf-cache": "Required to use throttle requests middleware"
},
"config": {
"sort-packages": true
Expand Down
21 changes: 21 additions & 0 deletions src/router/src/Exceptions/ThrottleRequestsException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace SwooleTW\Hyperf\Router\Exceptions;

use Throwable;

class ThrottleRequestsException extends TooManyRequestsHttpException
{
/**
* Create a new throttle requests exception instance.
*/
public function __construct(
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
parent::__construct(null, $message, $code, $previous);
}
}
27 changes: 27 additions & 0 deletions src/router/src/Exceptions/TooManyRequestsHttpException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace SwooleTW\Hyperf\Router\Exceptions;

use Hyperf\HttpMessage\Exception\HttpException;
use Throwable;

class TooManyRequestsHttpException extends HttpException
{
/**
* @param null|int|string $retryAfter The number of seconds or HTTP-date after which the request may be retried
*/
public function __construct(
null|int|string $retryAfter = null,
string $message = '',
int $code = 0,
Throwable $previous = null
) {
if ($retryAfter) {
$headers['Retry-After'] = $retryAfter;
}

parent::__construct(429, $message, $code, $previous);
}
}
319 changes: 319 additions & 0 deletions src/router/src/Middleware/ThrottleRequests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
<?php

declare(strict_types=1);

namespace SwooleTW\Hyperf\Router\Middleware;

use Closure;
use Hyperf\Collection\Arr;
use Hyperf\Support\Traits\InteractsWithTime;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;
use SwooleTW\Hyperf\Auth\Contracts\Authenticatable;
use SwooleTW\Hyperf\Cache\Exceptions\InvalidArgumentException;
use SwooleTW\Hyperf\Cache\RateLimiter;
use SwooleTW\Hyperf\Cache\RateLimiting\Unlimited;
use SwooleTW\Hyperf\Router\Exceptions\ThrottleRequestsException;
use SwooleTW\Hyperf\Support\Facades\Auth;

class ThrottleRequests
{
use InteractsWithTime;

/**
* The rate limiter instance.
*/
protected RateLimiter $limiter;

/**
* Indicates if the rate limiter keys should be hashed.
*/
protected static bool $shouldHashKeys = true;

/**
* Create a new request throttler.
*/
public function __construct(RateLimiter $limiter)
{
$this->limiter = $limiter;
}

/**
* Specify the named rate limiter to use for the middleware.
*/
public static function using(string $name): string
{
return static::class . ':' . $name;
}

/**
* Specify the rate limiter configuration for the middleware.
*/
public static function with(int $maxAttempts = 60, int $decayMinutes = 1, string $prefix = ''): string
{
return static::class . ':' . implode(',', func_get_args());
}

public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
int|string $maxAttempts = 60,
float|int|string $decayMinutes = 1,
string $prefix = ''
): ResponseInterface {
if (! is_numeric($decayMinutes)) {
throw new InvalidArgumentException('decayMinutes must be numeric.');
}

if (is_string($maxAttempts)
&& func_num_args() === 3
&& ! is_null($limiter = $this->limiter->limiter($maxAttempts))) {
return $this->handleRequestUsingNamedLimiter($request, $handler, $maxAttempts, $limiter);
}

return $this->handleRequest(
$request,
$handler,
[
(object) [
'key' => $prefix . $this->resolveRequestSignature(),
'maxAttempts' => $this->resolveMaxAttempts($maxAttempts),
'decayMinutes' => floatval($decayMinutes),
'responseCallback' => null,
],
]
);
}

/**
* Handle an incoming request.
*
* @throws ThrottleRequestsException
*/
protected function handleRequestUsingNamedLimiter(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
string $limiterName,
Closure $limiter
): ResponseInterface {
$limiterResponse = $limiter($request);

if ($limiterResponse instanceof ResponseInterface) {
return $limiterResponse;
}

if ($limiterResponse instanceof Unlimited) {
return $handler->handle($request);
}

return $this->handleRequest(
$request,
$handler,
collect(Arr::wrap($limiterResponse))->map(function ($limit) use ($limiterName) {
return (object) [
'key' => self::$shouldHashKeys ? md5($limiterName . $limit->key) : $limiterName . ':' . $limit->key,
'maxAttempts' => $limit->maxAttempts,
'decayMinutes' => $limit->decayMinutes,
'responseCallback' => $limit->responseCallback,
];
})->all()
);
}

/**
* Handle an incoming request.
*
* @throws ThrottleRequestsException
*/
protected function handleRequest(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
array $limits
): ResponseInterface {
foreach ($limits as $limit) {
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
return $this->resolveException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
}

$this->limiter->hit($limit->key, (int) round($limit->decayMinutes * 60));
}

$response = $handler->handle($request);

foreach ($limits as $limit) {
$response = $this->addHeaders(
$response,
$limit->maxAttempts,
$this->calculateRemainingAttempts($limit->key, $limit->maxAttempts)
);
}

return $response;
}

/**
* Resolve the number of attempts if the user is authenticated or not.
*/
protected function resolveMaxAttempts(int|string $maxAttempts): int
{
if (str_contains($maxAttempts, '|')) {
$maxAttempts = explode('|', $maxAttempts, 2)[$this->user() ? 1 : 0];
}

if (! is_numeric($maxAttempts) && $this->user()) {
$maxAttempts = $this->user()->{$maxAttempts};
}

return (int) $maxAttempts;
}

/**
* Resolve request signature.
*
* @throws RuntimeException
*/
protected function resolveRequestSignature(): string
{
if ($user = $this->user()) {
return $this->formatIdentifier($user->getAuthIdentifier());
}

$domain = $this->domain();
$ip = $this->ip();

if ($domain && $ip) {
return $this->formatIdentifier("{$domain}|{$ip}");
}

throw new RuntimeException('Unable to generate the request signature.');
}

/**
* Throw a 'too many attempts' exception.
*
* @return ThrottleRequestsException
*/
protected function resolveException(
ServerRequestInterface $request,
string $key,
int $maxAttempts,
?callable $responseCallback = null
): ResponseInterface {
if (is_callable($responseCallback)) {
$retryAfter = $this->getTimeUntilNextRetry($key);
$headers = $this->getHeaders(
$maxAttempts,
$this->calculateRemainingAttempts($key, $maxAttempts, $retryAfter),
$retryAfter
);

return $responseCallback($request, $headers);
}

throw new ThrottleRequestsException('Too Many Attempts.');
}

/**
* Get the number of seconds until the next retry.
*/
protected function getTimeUntilNextRetry(string $key): int
{
return $this->limiter->availableIn($key);
}

/**
* Add the limit header information to the given response.
*/
protected function addHeaders(
ResponseInterface $response,
int $maxAttempts,
int $remainingAttempts,
?int $retryAfter = null
): ResponseInterface {
$headers = $this->getHeaders($maxAttempts, $remainingAttempts, $retryAfter, $response);

foreach ($headers as $name => $value) {
$response = $response->withHeader($name, $value);
}

return $response;
}

/**
* Get the limit headers information.
*/
protected function getHeaders(
int $maxAttempts,
int $remainingAttempts,
?int $retryAfter = null,
?ResponseInterface $response = null
): array {
if ($response
&& ! is_null($response->getHeader('X-RateLimit-Remaining'))
&& (int) $response->getHeader('X-RateLimit-Remaining') <= (int) $remainingAttempts) {
return [];
}

$headers = [
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => $remainingAttempts,
];

if (! is_null($retryAfter)) {
$headers['Retry-After'] = $retryAfter;
$headers['X-RateLimit-Reset'] = $this->availableAt($retryAfter);
}

return $headers;
}

/**
* Calculate the number of remaining attempts.
*/
protected function calculateRemainingAttempts(string $key, int $maxAttempts, ?int $retryAfter = null): int
{
return is_null($retryAfter) ? $this->limiter->retriesLeft($key, $maxAttempts) : 0;
}

/**
* Format the given identifier based on the configured hashing settings.
*/
private function formatIdentifier(string $value): string
{
return self::$shouldHashKeys ? sha1($value) : $value;
}

/**
* Specify whether rate limiter keys should be hashed.
*/
public static function shouldHashKeys(bool $shouldHashKeys = true): void
{
self::$shouldHashKeys = $shouldHashKeys;
}

/**
* Get the currently authenticated user.
*/
protected function user(): ?Authenticatable
{
return Auth::user();
}

/**
* Get the currently request domain.
*/
protected function domain(): string
{
return preg_replace(';https?://;', '', url(''));
}

/**
* Get the currently request ip.
*/
protected function ip(): string
{
return request()->ip();
}
}

0 comments on commit 1d8a5ea

Please sign in to comment.