How to implement the rate limiter component on a symfony 5 project

July 24, 2021 MrAnyx 6 min de lecture

A rate limiter is basically a piece of code that will limit the number of request for a period of time. This is particularly usefull to restrict the access of an api for example. Symfony recently added this component on the version 5.2.

I recently wanted to implement the Rate Limiter component on a symfony project but I faced some issues during the development process.

In this tutorial, I will show you how to implement the rate limiter component on a symfony project.

Prerequisites

To enable and use the rate limiter component, you have to use symfony with a minimum version of 5.2.

A specific version of php is also needed. You need to use a version greater or equal to 7.2.5.

Installation

In order to use it, you will need to, first, install the package.

composer require symfony/rate-limiter

This is the only package you'll need to use this component. This command will automatically install the package but also every other packages that are need such as symfony/lock or symfony/options-resolver.

Configuration

Package file

As mentioned in the official documentation, you will first need to create a file called rate_limiter.yaml located in the config/packages folder with the following content.

# config/packages/rate_limiter.yaml

framework:
    rate_limiter:
        anonymous_api:
            policy: 'sliding_window'
            limit: 5
            interval: '1 minute'

The policy attribute can be equal to 3 different policies :

  • fixed_window
  • sliding_window
  • token_bucket

For more details about each of the policies above, I recommend you to take a look at the official documentation.

The limit attribute represents the number of requests allowed for a period of time. In this example, we'll use a value of 5 in order to easily show requests that are allowed are requests that are not allowed.

Finally, the interval attribute is used to specify a period of time. You can use any unit used in the relative datetime format.

Instead of using the interval attribute, you can also use the rate attribute. This attribute is an object that contains 2 fields :

  • interval
  • amout

For example, instead of using

interval: '5 minutes'

You use

rate: { interval: '5 minutes', amount: 100}

It means that, every 5 minutes, 100 requests can be made. But the total amout of requests can not exceed the limit value.

If you don't make all the requests in a period of time, they don't accumulate.

Environment variable

If you are using a PHP version that has been compiled with the --enable-sysvsem flag, you can directly go to the next section. But if it's not the case, you will have to make some more modifications.

You may face the following error :

Semaphore extension (sysvsem) is required.

Which mean that the Semaphore module isn't enabled in you version of PHP.

To fix it, two options are available :

  • Re-compile PHP using the --enable-sysvsem flag,
  • Simply change the LOCK_DNS value int the .env file.

I highly recomment the second option, it is way easier than the first one.

Initially, the LOCK_DNS value was equal to semaphore. To fix this error, you simply need to change it to flock.

Create a fake API

Let's create a fake API in order to test this component.

First, we'll create a controller using the make:controller available using the symfony console.

 php bin/console make:controller MainController

In this controller, we will create two routes.

  • The first will return json to fake an api,
  • The second will be a traditionnal HTTP route.

The objective is to limit the number of request the each api routes.

In this controller, we will simply paste the following content.

<?php

// src/Controller/MainController.php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class MainController extends AbstractController
{
    /**
     * @Route("/api", name="api_index")
     */
    public function index(): Response
    {
        return $this->json([
            'message' => 'Hello World!',
        ]);
    }

    /**
     * @Route("/", name="app_home")
     */
    public function home(): Response
    {
        return new Response("Hello world !");
    }
}

Something important is that each api route is prefixed with api_ and each HTTP route is prefixed with app_.

Now, if you serve your symfony application using :

php -S localhost:8080 -t public

And go to localhost:8080/api, you should see something like this :

{
    "message": "Hello World!"
}

Now, the only thing that remains is to implement the rate limite logic into our code.

Implement the rate limit logic

You could simply add the rate limit logic in each api route but will quickly become tedious. That why, i recomment you using an EventSubscriber to execute this same logic for every request.

To create en event subscriber, you just need to create a file, we will call it RateLimiterSubscriber.php but you can call it whatever you want, in the src/EventSubscriber folder.

The class contained in this new file must implement EventSubscriberInterface.

We will also need to add a private attribute to this class. This attribute must correspond to the rate limiter name in camel case followed by Limiter.

In out case, is the config/package/rate_limiter.yaml file, we've called it anonymous_api, so we will need to to call it anonymousApiLimiter like this.

// src/EventSubscriber/RateLimiterSubscriber.php

/**
 * @var RateLimiterFactory
 */
private $anonymousApiLimiter;

Now let's initialize this private attribute.

// src/EventSubscriber/RateLimiterSubscriber.php

public function __construct(RateLimiterFactory $anonymousApiLimiter)
{
    $this->anonymousApiLimiter = $anonymousApiLimiter;
}

We will next find the getSubscribedEvent function which is required by the EventSubscriberInterface. Because we want to execute the rate limiter logic for each request, we will call the onKernelRequest method for each RequestEvent.

// src/EventSubscriber/RateLimiterSubscriber.php

public static function getSubscribedEvents(): array
{
  return [
        RequestEvent::class => 'onKernelRequest',
  ];
}

Finally, we need to create the onKernelRequest which will contain the logic we want. In our case, we want to :

  • Retrieve the request,
  • Check if the requested route name contains api_,
  • Retrieve the limiter based on an indentifier,
  • Consume one request,
  • Check if the request is allowed (less than the limit).

This should look like this.

// src/EventSubscriber/RateLimiterSubscriber.php

public function onKernelRequest(RequestEvent $event): void {
    // Retrieve the request from the request event
  $request = $event->getRequest();

    // Check if the requested route name contains api_
  if(strpos($request->get("_route"), 'api_') !== false) {

        // Retrieve the limiter based on the request client IP
        $limiter = $this->anonymousApiLimiter->create($request->getClientIp());

        // Consume one request and check if it's still accepted
        if (false === $limiter->consume(1)->isAccepted()) {
          throw new TooManyRequestsHttpException();
        }
  }
}

By grouping each parts, we will end up with something like this :

<?php

// src/EventSubscriber/RateLimiterSubscriber.php

namespace App\EventSubscriber;

use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;

class RateLimiterSubscriber implements EventSubscriberInterface {

   /**
    * @var RateLimiterFactory
    */
   private $anonymousApiLimiter;

   public function __construct(RateLimiterFactory $anonymousApiLimiter) {
      $this->anonymousApiLimiter = $anonymousApiLimiter;
   }

   public static function getSubscribedEvents(): array {
      return [
         RequestEvent::class => 'onKernelRequest',
      ];
   }

   public function onKernelRequest(RequestEvent $event): void {
      $request = $event->getRequest();
      if(strpos($request->get("_route"), 'api_') !== false) {
         $limiter = $this->anonymousApiLimiter->create($request->getClientIp());
         if (false === $limiter->consume(1)->isAccepted()) {
            throw new TooManyRequestsHttpException();
         }
      }
   }
}

Now, if you go back to localhost:8080/api and press F5 more than 5 times in 1 minute, you should end up with a HTTP 429 Too Many Requests error which means that it is actually working 👍.

Conclusion

Now, you should be able to implement the rate limiter component in a symfony by yourself. By doing this, you can, in some ways, secure an api by allowing a certain amout of request in a period of time. I summed up every thing in this github repository : MrAnyx/test-rate-limiter.

Cover by Nathan Dumlao


Cette œuvre est mise à disposition selon les termes de la licence Licence Creative Commons