Home | Send Feedback

Rate limiting Spring MVC endpoints with bucket4j

Published: August 07, 2019  •  java, spring

Rate limiting is a technology used in networks to control the rate of traffic. We also see applications of this technology in the HTTP world. Services that provide an HTTP API often limit the number of requests that a client is allowed to send over a certain period of time.

For example, GitHub rate limits access to authenticated API calls to 5,000 per hour and 60 requests per hour for unauthenticated requests (associated with IP address): https://developer.github.com/v3/#rate-limiting

In this blog post, we are going to see how we can rate limit Spring MVC endpoints in Spring Boot applications.

Algorithm

Rate limiting can be implemented with many different algorithms:

The examples in this blog post use the bucket4j Java library which implements the token bucket algorithm.

The basics of the algorithm are easy to understand. You have a bucket that holds a maximum number of tokens (capacity). Whenever a consumer wants to call a service or consume a resource, he takes out one or multiple tokens. The consumer can only consume a service if he can take out the required number of tokens. If the bucket does not contain the required number of tokens, he needs to wait until there are enough tokens in the bucket.

When we have somebody that takes out tokens, we also need somebody that puts tokens into the bucket. The refiller periodically creates new tokens and puts them into the bucket.

token bucket

The bucket4j library differentiates between two types of refillers:

Refill.intervally(5, Duration.ofMinutes(1));
The "intervally"-refiller waits until the specified amount of time has passed and then puts in all tokens at once. In this example he adds 10 tokens every minute.

intervally

Refill.greedy(5, Duration.ofMinutes(1));
The "greedy"-refiller adds the tokens more greedily. With this example, he splits one minute into 5 periods and puts 1 token at each of these periods into the bucket. With the configuration above the refiller puts 1 token every 12 seconds into the bucket. After one minute both refillers put the same amount of tokens into the bucket.

greedy

Important to notice is that buckets have a maximum capacity. The refiller only puts tokens into the bucket until this capacity is reached. When the bucket is full (amount of tokens = capacity) the refiller does not put any tokens into the bucket.

For a more detailed description of the Token Bucket, algorithm check out the Wikipedia page and the token bucket algorithm overview from the bucket4j author.

Setup

Before we can use bucket4j, we need to add it to the classpath of our project. In a Maven managed project we add the following dependency to the pom.xml

    <dependency>
      <groupId>com.github.vladimir-bukhtoyarov</groupId>
      <artifactId>bucket4j-core</artifactId>
      <version>4.4.1</version>
    </dependency>

pom.xml

This library does not depend on any other library. You can use it with any framework that runs on the Java Virtual Machine.

Basic example

As an example server, I wrote a Spring Boot application that provides HTTP endpoints for fetching data about earthquakes that happened during the last week. The application automatically downloads the latest earthquake data from usgs.gov and inserts into a relational database with JOOQ.

The following endpoint returns information about the earthquake with the highest magnitude.

  @GetMapping("/top1")
  public ResponseEntity<Earthquake> getTop1() {
      Earthquake body = this.dsl.selectFrom(EARTHQUAKE).orderBy(EARTHQUAKE.MAG.desc())
          .limit(1).fetchOneInto(Earthquake.class);
      return ResponseEntity.ok().body(body);
  }

We now decide that we want to rate limit this service and only allow 10 requests per minute. First, we create the refiller, in this example, a greedy refiller that refills the bucket with 10 tokens per minute. That means this refiller will add one token every 6 seconds. Next, we create a Bandwith class which combines the maximum capacity of a bucket with the refiller, then we build the bucket with the Bucket4j builder.

  private final Bucket bucket;

  public EarthquakeController(DSLContext dsl) {
    this.dsl = dsl;

    long capacity = 10;
    Refill refill = Refill.greedy(10, Duration.ofMinutes(1));
    Bandwidth limit = Bandwidth.classic(capacity, refill);
    this.bucket = Bucket4j.builder().addLimit(limit).build();

EarthquakeController.java

The bucket we create here is global, so every client request is handled by the same bucket. Later in this blog post, we are going to see examples that configure buckets on a per-client basis.

bucket4j provides a convenience method simple() that combines the creation of the refiller and bandwidth in one call. This method internally calls classic and is useful when you want to create a bucket with a greedy refiller and where the amount of refill tokens is the same as the capacity of the bucket.

Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1));

It is worth noting that when you create the Bandwith instance with either simple() or classic(), the bucket is automatically filled with the same number of tokens as the maximum capacity. In this example, the bucket contains after the creation 10 tokens. Sometimes this is not the desired effect, and you want to start with a different amount of tokens. In these cases you call withInitialTokens(numTokens).

Bandwidth limit = Bandwidth.classic(capacity, refill).withInitialTokens(1);

With the additional withInitialTokens(1) call the bucket starts with 1 token.

With the bucket in place, we can now add the rate-limiting code to our endpoint. To get a token from the bucket, we call the method tryConsume(numTokens). This method returns true if it was able to consume the given number of tokens from the bucket, false when the bucket is empty or does not contain the required number of tokens. The argument passed to the tryConsume() must be a positive number, it does not have to be just 1 token, you can decide that a service is more expensive and consumes 2, 3 or more tokens.

  @GetMapping("/top1")
  public ResponseEntity<Earthquake> getTop1() {
    if (this.bucket.tryConsume(1)) {
      Earthquake body = this.dsl.selectFrom(EARTHQUAKE).orderBy(EARTHQUAKE.MAG.desc())
          .limit(1).fetchOneInto(Earthquake.class);
      return ResponseEntity.ok().body(body);
    }

    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
  }

EarthquakeController.java

For HTTP endpoints its common to send back the status code 429 (TOO_MANY_REQUESTS) if the calling client exceeded the configured rate limit. This is not a hard requirement; your service can do whatever he wants if the clients exceed the rate limit. However, this is a standard convention, and you should follow it, especially when you create a public-facing HTTP API.

We can now test the rate limiter. For this purpose, I wrote a simple client with the Java 11 HTTP client.

      for (int i = 0; i < 11; i++) {
        Builder builder = HttpRequest.newBuilder().uri(URI.create(SERVER_1 + "/top1"))
            .GET();
        HttpRequest request = builder.build();
        HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString());
        System.out.println(response.statusCode());
      }

Client.java

If we configured everything correctly, the first 10 GET calls return the status code 200, and the 11th call returns 429 because the bucket is empty. Our client now has to wait 6 seconds until the refiller adds 1 token to the bucket and we can send another GET request.

Return additional rate limit information

Often you not only want just to send back the status code 429 but also more information, like the remaining number of tokens or the remaining time until the bucket is refilled.

For this purpose instead of calling bucket.tryConsume(), call the method bucket.tryConsumeAndReturnRemaining() which returns an instance of ConsumptionProbe. This object contains the result of consumption, which we can access with isConsumed(), as well as the remaining number of tokens (getRemainingTokens()) and the time in nanoseconds until the next refill (getNanosToWaitForRefill()).

getNanosToWaitForRefill() returns the time only when isConsumed() returns false. In case of a successful token consumption getNanosToWaitForRefill() returns 0.

In this example, we leverage the same bucket as before, but we send back additional HTTP response headers. In case the call was successful, the endpoint sends back X-Rate-Limit-Remaining with the remaining number of tokens. The client then knows how many more requests he can send before the rate limit kicks in.

In case the client exceeds the limit the endpoint sends back the header X-Rate-Limit-Retry-After-Milliseconds, which tells the client how long (in milliseconds) he has to wait before sending a new request and expecting a successful response.

  @GetMapping("/top/{top}")
  public ResponseEntity<List<Earthquake>> getTopOrderByMag(@PathVariable("top") int top) {
    ConsumptionProbe probe = this.bucket.tryConsumeAndReturnRemaining(1);
    if (probe.isConsumed()) {
      List<Earthquake> body = this.dsl.selectFrom(EARTHQUAKE)
          .orderBy(EARTHQUAKE.MAG.desc()).limit(top).fetchInto(Earthquake.class);
      return ResponseEntity.ok()
          .header("X-Rate-Limit-Remaining", Long.toString(probe.getRemainingTokens()))
          .body(body);
    }

    // X-Rate-Limit-Retry-After-Seconds
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
        .header("X-Rate-Limit-Retry-After-Milliseconds",
            Long.toString(TimeUnit.NANOSECONDS.toMillis(probe.getNanosToWaitForRefill())))
        .build();
  }

EarthquakeController.java

We can now test this endpoint with the following client.

    for (int i = 0; i < 11; i++) {
      get(SERVER_1 + "/top/3");
    }

Client.java

  private static void get(String url, String apiKey) {
    try {
      Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).GET();
      if (apiKey != null) {
        builder.header("X-api-key", apiKey);
      }
      HttpRequest request = builder.build();
      HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString());
      System.out.print(response.statusCode());
      if (response.statusCode() == 200) {
        String remaining = response.headers().firstValue("X-Rate-Limit-Remaining")
            .orElse(null);
        System.out.println(" Remaining: " + remaining);
      }
      else {
        String retry = response.headers()
            .firstValue("X-Rate-Limit-Retry-After-Milliseconds").orElse(null);
        System.out.println(" retry after milliseconds: " + retry);
      }
    }
    catch (IOException | InterruptedException e) {
      e.printStackTrace();
    }
  }

Client.java

The first 10 GET calls return the status code 200 with the remaining number of tokens and the 11th call fails with 429 and sends back the header X-Rate-Limit-Retry-After-Milliseconds.

200 Remaining: 9
200 Remaining: 8
200 Remaining: 7
200 Remaining: 6
200 Remaining: 5
200 Remaining: 4
200 Remaining: 3
200 Remaining: 2
200 Remaining: 1
200 Remaining: 0
429 retry after milliseconds: 5863

Rate limit with Spring MVC Interceptor

In the two examples above, we mixed the rate limit code with the business code. This might be acceptable if we only want to protect one or two services. However, as soon as we have to rate limit multiple endpoints, we're end up writing a lot of duplicate code.

It would be better to separate these two concerns. With Spring MVC, we can implement this in different ways. In the following example, we are going to extract the rate limit code into a Spring MVC interceptor. An interceptor allows an application to intercept HTTP requests before they reach the service and after they come back. Here we are only interested in the before case so we can either let the request go through or block it and send back the status code 429.

Here an example of a generic rate limit interceptor. The bucket, as well as the number of consumption tokens, are passed to the constructor. This way we can construct multiple interceptors with different bucket configurations.

The preHandle() method is called by Spring MVC before it sends the request to the next interceptor or the handler itself. The interceptor either returns true, Spring MVC then sends the request to the next component in the chain, or the interceptor returns false which signifies Spring MVC that the request was handled by the interceptor and sends the response back to the client.

The rate limit code is the same as in the previous example.

public class RateLimitInterceptor implements HandlerInterceptor {

  private final Bucket bucket;

  private final int numTokens;

  public RateLimitInterceptor(Bucket bucket, int numTokens) {
    this.bucket = bucket;
    this.numTokens = numTokens;
  }

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
      Object handler) throws Exception {

    ConsumptionProbe probe = this.bucket.tryConsumeAndReturnRemaining(this.numTokens);
    if (probe.isConsumed()) {
      response.addHeader("X-Rate-Limit-Remaining",
          Long.toString(probe.getRemainingTokens()));
      return true;
    }

    response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // 429
    response.addHeader("X-Rate-Limit-Retry-After-Milliseconds",
        Long.toString(TimeUnit.NANOSECONDS.toMillis(probe.getNanosToWaitForRefill())));

    return false;

  }

RateLimitInterceptor.java

Next, we need to configure the interceptor and specify which URLs we want to handle by this interceptor. The following example protects the /last endpoint with a basket that allows 10 requests per minute and the /place/... endpoint with a basket that allows only 3 requests per minute.

@SpringBootApplication
public class Application implements WebMvcConfigurer {

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    Refill refill = Refill.greedy(10, Duration.ofMinutes(1));
    Bandwidth limit = Bandwidth.classic(10, refill).withInitialTokens(1);
    Bucket bucket = Bucket4j.builder().addLimit(limit).build();
    registry.addInterceptor(new RateLimitInterceptor(bucket, 1)).addPathPatterns("/last");

    refill = Refill.intervally(3, Duration.ofMinutes(1));
    limit = Bandwidth.classic(3, refill);
    bucket = Bucket4j.builder().addLimit(limit).build();
    registry.addInterceptor(new RateLimitInterceptor(bucket, 1))
        .addPathPatterns("/place/*");

Application.java

The code for these two endpoints is free from any rate limit code.

  @GetMapping("/last")
  public Earthquake getLast() {
    return this.dsl.selectFrom(EARTHQUAKE).orderBy(EARTHQUAKE.TIME.desc()).limit(1)
        .fetchOneInto(Earthquake.class);
  }

  @GetMapping("/place/{place}")
  public List<Earthquake> getPlace(@PathVariable("place") String place) {
    return this.dsl.selectFrom(EARTHQUAKE).where(EARTHQUAKE.PLACE.endsWith(place))
        .fetchInto(Earthquake.class);
  }

EarthquakeController.java

With this architecture in place, we can easily adjust the parameters of the buckets in one place, only have to write the rate limit code once and no longer pollute our business code with rate limit code.

Rate limit in a cluster

When the number of requests grows, you might need to scale out and run multiple instances of your application. In such an environment, the buckets we used so far would only rate limit requests per JVM instance. Even in an installation with multiple application instances, you often have to access resources that exist only once, like a database. So instead of rate limit per JVM, we want to rate limit across the whole cluster.

Fortunately for us, this can easily be achieved with Spring Boot and bucket4j. bucket4j provides adapters for popular products like Hazelcast, Apache Ignite, Infinispan, and Oracle Coherence. We are going to look at an example with Hazelcast.

First, we need to add the required libraries to our Spring Boot project.

    <dependency>
      <groupId>com.github.vladimir-bukhtoyarov</groupId>
      <artifactId>bucket4j-hazelcast</artifactId>
      <version>4.4.1</version>
    </dependency>
    <dependency>
      <groupId>com.hazelcast</groupId>
      <artifactId>hazelcast</artifactId>
    </dependency>
    <dependency>
      <groupId>com.hazelcast</groupId>
      <artifactId>hazelcast-spring</artifactId>
    </dependency>
    <dependency>
      <groupId>javax.cache</groupId>
      <artifactId>cache-api</artifactId>
    </dependency>

pom.xml

Spring Boot auto-configures Hazelcast if it finds the library on the classpath. All we have to provide in our code is a Config bean. With this in place, we can inject an instance of HazelcastInstance into any Spring-managed bean.

  @Autowired
  private HazelcastInstance hzInstance;

  @Bean
  public Config hazelCastConfig() {
    Config config = new Config();
    config.setInstanceName("my-hazelcast-instance");
    return config;
  }

Application.java

The next step is to get an instance of IMap. Hazelcast automatically synchronizes the content of this map across all nodes of a Hazlcast cluster.

The code that creates the bucket changes a bit, and we have to call extension(Hazelcast.class) so bucket4j is aware that we want to create a clustered bucket. We now have to provide 3 arguments to the build() method. A reference to the IMap, the key of this bucket and a strategy that determines what bucket4j should do if the previously saved state of the bucket was lost. bucket4j either re-initializes it with RecoveryStrategy.RECONSTRUCT or throws an exception with RecoveryStrategy.THROW_BUCKET_NOT_FOUND_EXCEPTION

It's essential that when you create multiple buckets that each one has a unique key (2nd argument). This is the key under which bucket4j stores the bucket in the IMap.

    IMap<String, GridBucketState> map = this.hzInstance.getMap("bucket-map");
    bucket = Bucket4j.extension(Hazelcast.class).builder().addLimit(limit).build(map,
        "rate-limit", RecoveryStrategy.RECONSTRUCT);
    registry.addInterceptor(new RateLimitInterceptor(bucket, 1))
        .addPathPatterns("/place/*");

Application.java

This is all we have to change to enable a clustered bucket. Thanks to the generic interceptor we don't have to change any code in the handler with our business logic.

To test this setup start the application two times with the following commands. We need to make sure that we bind the HTTP server to two different ports.

.\mvnw.cmd spring-boot:run -Dspring-boot.run.arguments=--server.port=8080
.\mvnw.cmd spring-boot:run -Dspring-boot.run.arguments=--server.port=8081

.\mvnw.cmd works only on Windows on macOS and Linux you use the command /mvnw instead

The bucket we are using here only allows 3 requests per minute. So we can easily see the effect of the clustered bucket when we send requests to both servers.

    for (int i = 0; i < 4; i++) {
      get(SERVER_1 + "/place/Alaska");
    }
    for (int i = 0; i < 4; i++) {
      get(SERVER_2 + "/place/Alaska");
    }

Client.java

After 3 requests we get back the status 429 and when we send the next requests to the 8081 server all return 429. With the previous non-clustered bucket our client was able to send 3 requests to 8080 and 3 to 8081 before he got the 429 status code.

Rate limit per client

All the examples above created one bucket, and all clients had to share this single bucket. This is one way you can rate limit your service. Another approach is to limit the number of requests per client. For this approach, you have to build a bucket per client.

You can either use the same limit for all clients or assign individual rate limits.

In the following example, we give out a unique API key to each client, which he has to send in the header of the HTTP request. In this contrived example API keys that start with "1" denote premium customers that are allowed to send 100 requests per minute, the other clients are standard customers that can send 50 requests per minute. And we also provide a free tier where a client can send 10 requests in a minute. Notice that all clients in the free tier share one single bucket. Every free tier client has to compete for these 10 tokens, unlike clients with an API key that have their own bucket.

The implementation is all contained in an interceptor. The code stores the buckets for the clients with the API key in a map. Each time when a request comes in with an API key, the program fetches the bucket from the map. If the bucket does not yet exist, it creates a new bucket and stores it in the map.

The instance variable freeBucket references the singleton bucket for clients without an API key.

public class PerClientRateLimitInterceptor implements HandlerInterceptor {

  private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

  private final Bucket freeBucket = Bucket4j.builder()
      .addLimit(Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(1))))
      .build();

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
      Object handler) throws Exception {

    Bucket requestBucket;

    String apiKey = request.getHeader("X-api-key");
    if (apiKey != null && !apiKey.isBlank()) {
      if (apiKey.startsWith("1")) {
        requestBucket = this.buckets.computeIfAbsent(apiKey, key -> premiumBucket());
      }
      else {
        requestBucket = this.buckets.computeIfAbsent(apiKey, key -> standardBucket());
      }
    }
    else {
      requestBucket = this.freeBucket;
    }

    ConsumptionProbe probe = requestBucket.tryConsumeAndReturnRemaining(1);
    if (probe.isConsumed()) {
      response.addHeader("X-Rate-Limit-Remaining",
          Long.toString(probe.getRemainingTokens()));
      return true;
    }

    response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // 429
    response.addHeader("X-Rate-Limit-Retry-After-Milliseconds",
        Long.toString(TimeUnit.NANOSECONDS.toMillis(probe.getNanosToWaitForRefill())));

    return false;
  }

  private static Bucket standardBucket() {
    return Bucket4j.builder()
        .addLimit(Bandwidth.classic(50, Refill.intervally(50, Duration.ofMinutes(1))))
        .build();
  }

  private static Bucket premiumBucket() {
    return Bucket4j.builder()
        .addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
        .build();
  }

}

PerClientRateLimitInterceptor.java

Lastly, we register the interceptor. In this example, we rate limit all requests to /depth/... with this interceptor

    registry.addInterceptor(new PerClientRateLimitInterceptor())
        .addPathPatterns("/depth/**");

Application.java

Rate limit per client in a cluster

The previous example works fine as long as we only have one instance of our application running. If we run multiple instances and want to synchronize the bucket state across the whole cluster, we can again use Hazelcast (or any other grid library supported by bucket4j).

Like in the Hazelcast example above we need to add the necessary libraries to the classpath, and we need to configure Spring Boot.

With the proper configuration in place, we can now write our per client clustered rate limiter. Instead of managing the bucket map ourselves, we can leverage the ProxyManager class from bucket4j. As the argument to the builder method proxyManagerForMap() we need to pass an instance to a Hazelcast IMap. The content of an IMap is automatically synchronized across all nodes of the Hazelcast cluster.

The ProxyManager works like a map. We can access buckets with a key. The ProxyManager returns either an already created bucket or if the bucket does not exist calls the Supplier which creates the bucket, then stores it under the given key and returns the bucket.

public class PerClientHazelcastRateLimitInterceptor implements HandlerInterceptor {

  private final ProxyManager<String> buckets;

  public PerClientHazelcastRateLimitInterceptor(HazelcastInstance hzInstance) {
    this.buckets = Bucket4j.extension(Hazelcast.class)
        .proxyManagerForMap(hzInstance.getMap("per-client-bucket-map"));
  }

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
      Object handler) throws Exception {

    String apiKey = request.getHeader("X-api-key");
    if (apiKey == null || apiKey.isBlank()) {
      apiKey = "free";
    }
    Bucket requestBucket = this.buckets.getProxy(apiKey, getConfigSupplier(apiKey));

    ConsumptionProbe probe = requestBucket.tryConsumeAndReturnRemaining(1);
    if (probe.isConsumed()) {
      response.addHeader("X-Rate-Limit-Remaining",
          Long.toString(probe.getRemainingTokens()));
      return true;
    }

    response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // 429
    response.addHeader("X-Rate-Limit-Retry-After-Milliseconds",
        Long.toString(TimeUnit.NANOSECONDS.toMillis(probe.getNanosToWaitForRefill())));

    return false;
  }

  private static Supplier<BucketConfiguration> getConfigSupplier(String apiKey) {
    return () -> {
      if (apiKey.startsWith("1")) {
        return Bucket4j.configurationBuilder()
            .addLimit(
                Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
            .build();
      }
      else if (!apiKey.equals("free")) {
        return Bucket4j.configurationBuilder()
            .addLimit(Bandwidth.classic(50, Refill.intervally(50, Duration.ofMinutes(1))))
            .build();
      }
      return Bucket4j.configurationBuilder()
          .addLimit(Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(1))))
          .build();

    };
  }

}

PerClientHazelcastRateLimitInterceptor.java

Multiple refillers

All the buckets in these examples use one refiller. bucket4j also supports multiple refillers per bucket. Here an example from the documentation that allows 1000 requests per minute but not more than 50 requests per second.

Bucket bucket = Bucket4j.builder()
       .addLimit(Bandwidth.simple(1000, Duration.ofMinutes(1)))
       .addLimit(Bandwidth.simple(50, Duration.ofSeconds(1)))
       .build();

This kind of configuration is something you need to consider for a production environment.

See more information in the bucket4j documentation here and here.

Client

All the examples we have seen so far are using bucket4j for rate limit an HTTP endpoint. You can also use the library on the client for calling a rate-limited service.

The /top/... service allows 10 requests per minute. We can write a client that sends requests to this endpoint at the same rate. First, we create the bucket and use the same configuration we use on the server. The important part is that we convert the bucket into a BlockingBucket with bucket.asScheduler(). The method consume() we call on such a bucket either returns immediately when it was able to consume the requested number of tokens or it blocks the calling thread and waits until the required number of tokens are available in the bucket.
As soon as there are enough tokens, it wakes up, consumes the tokens and the calling thread resumes his work.

    Refill refill = Refill.greedy(10, Duration.ofMinutes(1));
    Bandwidth limit = Bandwidth.classic(10, refill).withInitialTokens(1);
    Bucket bucket = Bucket4j.builder().addLimit(limit).build();
    BlockingBucket blockingBucket = bucket.asScheduler();

    for (int i = 0; i < 10; i++) {
      blockingBucket.consume(1);
      get(SERVER_1 + "/top/3");
    }

Client.java

You can use this pattern for any program where you have to limit the number of operations over a period of time.


You've reached the end of this introduction to rate limiting with bucket4j and Spring MVC. Check out the bucket4j project page for more in-depth information and examples.

The source code of all examples in this blog post is hosted on GitHub:
https://github.com/ralscha/blog2019/tree/master/ratelimit