1. About Bucket4j
1.1. What is Bucket4j
Bucket4j is a Java rate-limiting library based on the token-bucket algorithm, which is the de-facto standard for rate-limiting in the IT industry.
|
Important
|
Bucket4j is more than a plain implementation of token-bucket
Its math model provides several useful extensions that are not present in the classic token-bucket interpretation, such as multiple limits per bucket or overdraft. These extensions are described in detail later in this document.
|
You can read more about the token-bucket algorithm here:
-
Token bucket - the Wikipedia page describing the token-bucket algorithm in its classical form.
-
Non-formal overview of the token-bucket algorithm - a brief, informal overview of the algorithm.
1.2. Bucket4j basic features
-
Uncompromising precision - Bucket4j never uses floats or doubles; all calculations are performed with integer arithmetic. This protects you from rounding errors.
-
Efficient under concurrency:
-
Bucket4j scales well for multithreaded use cases and uses a lock-free implementation by default.
-
At the same time, the library provides several other concurrency strategies that you can choose from when the default lock-free strategy is not what you need.
-
-
Low garbage-collector footprint - the API relies on primitive types wherever possible to avoid boxing and other kinds of transient garbage.
-
Pluggable listener API for implementing monitoring and logging.
-
Rich diagnostic API for inspecting the internal state of a bucket.
-
Flexible configuration management - the configuration of a bucket can be changed on the fly, without recreating the bucket.
1.3. Bucket4j distributed features
In addition to the basic features described above, Bucket4j lets you implement rate-limiting across a cluster of JVMs:
-
Bucket4j supports any grid solution compatible with the JCache API (JSR 107) specification out of the box.
-
Bucket4j provides a framework that lets you quickly build an integration with your own persistent storage, such as an RDBMS or a key-value store.
-
For clustered scenarios, Bucket4j provides an asynchronous API, which matters a lot in distributed systems because it lets you avoid blocking your application threads every time a network call is required.
2. Basic functionality
2.1. Quick start examples
2.1.1. Adding the Bucket4j dependency
Bucket4j is distributed through Maven Central. Add the dependency below to your project so that you can compile and run the examples that follow.
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.20.0</version>
</dependency>
|
Note
|
See the Java compatibility matrix if you need a build that is compatible with Java 8. |
2.1.2. Limiting the rate of access to a REST API
Imagine that you are building yet another social network and want to expose a REST API to third-party developers. To protect your system from being overloaded, you want to introduce the following limitation:
The bucket size is 50 calls (which cannot be exceeded at any given time), with a "refill rate" of 10 calls per second that continuously adds tokens to the bucket. In other words, if the client app averages 10 calls per second, it will never be throttled. Moreover, the client has an overdraft of 50 calls, which can be used if the average is a little higher than 10 calls/sec over a short period of time.
Constructing a bucket that satisfies the requirements above is a little more complicated than the previous examples because we have to deal with the overdraft, but it is still fairly simple:
import io.github.bucket4j.Bucket;
public class ThrottlingFilter implements javax.servlet.Filter {
private Bucket createNewBucket() {
return Bucket.builder()
.addLimit(limit -> limit.capacity(50).refillGreedy(10, Duration.ofSeconds(1)))
.build();
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
HttpSession session = httpRequest.getSession(true);
String appKey = SecurityUtils.getThirdPartyAppKey();
Bucket bucket = (Bucket) session.getAttribute("throttler-" + appKey);
if (bucket == null) {
bucket = createNewBucket();
session.setAttribute("throttler-" + appKey, bucket);
}
// tryConsume returns false immediately if no tokens available with the bucket
if (bucket.tryConsume(1)) {
// the limit is not exceeded
filterChain.doFilter(servletRequest, servletResponse);
} else {
// limit is exceeded
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setContentType("text/plain");
httpResponse.setStatus(429);
httpResponse.getWriter().append("Too many requests");
}
}
}
If you want to provide more information to the end-user about the state of the bucket, then the last fragment of code above can be rewritten in the following way:
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
// the limit is not exceeded
httpResponse.setHeader("X-Rate-Limit-Remaining", "" + probe.getRemainingTokens());
filterChain.doFilter(servletRequest, servletResponse);
} else {
// limit is exceeded
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setStatus(429);
httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()));
httpResponse.setContentType("text/plain");
httpResponse.getWriter().append("Too many requests");
}
2.1.3. Specifying the initial amount of tokens
By default, the initial size of the bucket equals its capacity. But sometimes you may want a smaller initial size - for example, to avoid a thundering-herd effect on a cold start:
int initialTokens = 42;
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(1000).refillGreedy(1000, ofHours(1)).initialTokens(initialTokens))
.build();
2.1.4. Returning tokens back to the bucket
A compensating transaction is one of the obvious use cases for returning tokens back to the bucket:
Bucket wallet;
...
if (wallet.tryConsume(50)) { // get 50 cents from wallet
try {
buyCocaCola();
} catch(NoCocaColaException e) {
// return money to wallet
wallet.addTokens(50);
}
}
2.1.5. Customizing time measurement - nanosecond resolution
By default, Bucket4j measures time with millisecond resolution, which is the preferred strategy for most use cases. In rare cases (for example, benchmarking, or bandwidths whose period is too short to be measured reliably in milliseconds) you may want nanosecond resolution instead:
Bucket.builder().withNanosecondPrecision()
Be careful when choosing this strategy: System.nanoTime() is not tied to wall-clock time and its value cannot be compared across JVM instances,
so it must not be used for buckets whose state is shared between processes (for example distributed buckets). Use it only within a single JVM, and only when
millisecond resolution is genuinely too coarse for the period of your bandwidth.
2.1.6. Customizing time measurement - a custom time measurement strategy
You can specify your own time meter if neither the millisecond nor the nanosecond time meter fits your needs. For example, imagine that you have a clock that synchronizes its time with other machines in the cluster, and you want to use the time provided by this clock instead of the time provided by the JVM:
public class ClusteredTimeMeter implements TimeMeter {
@Override
public long currentTimeNanos() {
return ClusteredClock.currentTimeMillis() * 1_000_000;
}
}
Bucket bucket = Bucket.builder()
.withCustomTimePrecision(new ClusteredTimeMeter())
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build();
2.1.7. Blocking API example
Suppose you are implementing a consumer of messages from a messaging system, and you want to process messages no faster than a desired rate:
// define a bucket with capacity 100 and a refill of 100 tokens per minute
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build();
// poll in an infinite loop
while (true) {
List<Message> messages = consumer.poll();
for (Message message : messages) {
// Consume a token from the bucket. If no token is available, this method blocks until the refill adds one.
bucket.asBlocking().consume(1);
process(message);
}
}
2.2. Concepts
2.2.1. Bucket
Bucket is a rate-limiter built on top of the ideas of the well-known token-bucket algorithm.
In the Bucket4j code, Bucket is represented by the interface io.github.bucket4j.Bucket.
-
BucketConfiguration - an immutable collection of limitation rules used by the bucket.
-
BucketState - the place where the bucket stores mutable state, such as the amount of currently available tokens.
A bucket is constructed via the BucketBuilder builder API, available through the following factory method:
Bucket bucket = Bucket.builder()
.addLimit(...)
.build();
2.2.2. BucketConfiguration
BucketConfiguration is a collection of limits used by the Bucket during its work.
In the Bucket4j code, BucketConfiguration is represented by the class io.github.bucket4j.BucketConfiguration.
The configuration is immutable - there is no way to add or remove a limit from an already-created configuration. However, you can replace the configuration of a bucket by creating a new configuration instance and calling bucket.replaceConfiguration(newConfiguration) (see On-the-fly configuration replacement).
Usually you should not create a BucketConfiguration directly (except for the configuration-replacement case), because the BucketBuilder does it for you behind the scenes.
For the rare cases when you need to create a configuration directly, use ConfigurationBuilder, available through the following factory method:
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(...)
.build()
|
Important
|
Most users configure a single limit per configuration, but it is strongly recommended to check whether the short-timed bursts problem could affect your application, and if so, consider adding more limits. |
2.2.3. Limitation/Bandwidth
Limitations applied by a bucket are described in terms of bandwidths. A bandwidth is defined by the following properties:
- Capacity
-
Capacityis inherited directly from the classic interpretation of the token-bucket algorithm - it specifies how many tokens the bucket can hold. Capacity must be configured at build time:
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(42))
.build()
- Refill
-
Refill specifies how fast tokens are replenished after being consumed from the bucket. Refill must also be configured at build time:
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(...).refillXXX(...)) // where XXX is a specific refill style
.build()
Bucket4j lets you choose from several Refill types.
- Initial tokens
-
Bucket4j extends the classic token-bucket algorithm by letting you specify the initial amount of tokens for each bandwidth. By default, the initial amount of tokens equals the capacity, and it can be changed via the
initialTokensmethod:Bucket bucket = Bucket.builder() .addLimit(limit -> limit.capacity(42).refillGreedy(1, ofSeconds(1)).initialTokens(13)) .build() - Bandwidth ID
-
The identifier is an optional attribute that is
nullby default. Assign identifiers to bandwidths when a bucket has more than one bandwidth and you rely on on-the-fly configuration replacement; otherwise, it is better to avoid identifiers to save memory. An identifier can be specified as follows:BucketConfiguration configuration = BucketConfiguration.builder() .addLimit(limit -> limit.capacity(1000).refillGreedy(1000, ofMinutes(1)).id("business-limit")) .addLimit(limit -> limit.capacity(100).refillGreedy(100, ofSeconds(1)).id("burst-protection")) .build();NoteIdentifiers matter for on-the-fly configuration replacement, because during replacement Bucket4j needs to decide how to correctly carry over the number of already-consumed tokens from the state before replacement to the state after replacement. This is not trivial, especially when the number of limits changes - see On-the-fly configuration replacement for details.
2.2.4. Refill styles
Bucket4j lets you choose between several styles in which consumed tokens are refilled back into the bucket.
- Greedy
-
This type of refill regenerates tokens as greedily as possible - it tries to add tokens to the bucket as soon as it can. For example, a refill of "10 tokens per 1 second" adds 1 token every 100 milliseconds, rather than waiting a full second to add all 10 tokens at once. The three refills below regenerate tokens at the same speed:
Bucket.builder().addLimit(limit -> limit.capacity(1000).refillGreedy(600, ofMinutes(1))) Bucket.builder().addLimit(limit -> limit.capacity(1000).refillGreedy(10, ofSeconds(1))) Bucket.builder().addLimit(limit -> limit.capacity(1000).refillGreedy(1, ofMillis(100))) - Intervally
-
This type of refill regenerates tokens in an interval-based manner. Unlike "greedy", "intervally" waits until the whole period has elapsed before regenerating the entire amount of tokens at once.
Example:// refills 100 tokens each minute Bucket bucket = Bucket.builder().addLimit(limit -> limit.capacity(1000).refillIntervally(100, ofMinutes(1))).build(); - IntervallyAligned
-
This type of refill also regenerates tokens in an interval-based manner - like "intervally", it waits until the whole period has elapsed before regenerating the entire amount of tokens at once. In addition, it lets you specify the moment in time when the first refill should happen, which is useful for aligning refills to a clear interval boundary, such as the start of a second, minute, hour, or day.
Example:// imagine that the wall clock reads 16:20; the first refill will happen at 17:00, // i.e. at the beginning of the next hour Instant firstRefillTime = ZonedDateTime.now() .truncatedTo(ChronoUnit.HOURS) .plus(1, ChronoUnit.HOURS) .toInstant(); Bucket bucket = Bucket.builder().addLimit(limit -> limit.capacity(400).refillIntervallyAligned(400, ofHours(1), firstRefillTime)).build(); - RefillIntervallyAlignedWithAdaptiveInitialTokens
-
This refill style behaves like
IntervallyAligned, but instead of starting with a full bucket it calculates the initial amount of tokens adaptively, based on how much of the current period has already elapsed before the first refill. This is useful when you want a clean interval boundary (for example, refilling at the start of every hour) without granting a full bucket of tokens to every client the moment it is created.The initial amount of tokens is calculated using the following formula:
initialTokens = min(capacity, max(0, capacity - refillTokens) + elapsedFractionOfPeriod * refillTokens)Example:// imagine that the wall clock reads 16:20, so 20 out of the 60 minutes of the current hour have already elapsed; // the first refill will happen at the start of the next hour, at 17:00 Instant firstRefillTime = ZonedDateTime.now() .truncatedTo(ChronoUnit.HOURS) .plus(1, ChronoUnit.HOURS) .toInstant(); Bucket bucket = Bucket.builder() .addLimit(limit -> limit.capacity(400).refillIntervallyAlignedWithAdaptiveInitialTokens(400, ofHours(1), firstRefillTime)) .build(); // initial tokens = min(400, max(0, 400 - 400) + 40/60 * 400) = min(400, 0 + 266) = 266NoteBecause this strategy needs to measure how far the current moment is from timeOfFirstRefill, it cannot be combined with the nanosecond-based clock enabled bywithNanosecondPrecision()- it requires aSystem.currentTimeMillis()-based clock. It is also incompatible with explicitly specifyinginitialTokens()for the same bandwidth, because the initial amount of tokens is already computed adaptively.
2.2.5. BucketState
BucketState is the place where a bucket stores its own mutable state, such as:
-
The amount of currently available tokens.
-
The timestamp of the last refill.
BucketState is represented by the class io.github.bucket4j.BucketState. You normally never interact with this class directly, except when you need access to the low-level diagnostic API described in Verbose/Debug API.
2.2.6. BucketBuilder
The library authors deliberately decided not to let end-users construct library entities via direct constructors.
-
It allows internal implementations to change in the future without breaking backward compatibility.
-
It provides a
Fluent Builder API, which we consider a good, modern library design pattern.
LocalBucketBuilder is a fluent builder specialized for constructing local buckets, where a local bucket is a bucket that holds its state purely in memory and does not provide clustering functionality. Below is an example of LocalBucketBuilder usage:
Bucket bucket = Bucket.builder()
.addLimit(...)
.withNanosecondPrecision()
.withSynchronizationStrategy(SynchronizationStrategy.LOCK_FREE)
.build()
2.3. Listening for bucket events
2.3.1. What can be listened to
-
When tokens are consumed from a bucket.
-
When a consumption request is rejected by the bucket.
-
When a thread is parked to wait for a refill, as a result of interaction with
BlockingBucket. -
When a thread is interrupted while waiting for a refill, as a result of interaction with
BlockingBucket. -
When a delayed task is submitted to a
ScheduledExecutorService, as a result of interaction withAsyncScheduledBucket.
2.3.2. Listener API - corner cases
Question: How many listeners do I need if my application uses many buckets?
Answer: It depends:
-
If you want aggregated statistics across all buckets, create a single listener per application and reuse it for all buckets.
-
If you want to measure statistics per bucket, use a separate listener per bucket.
Question: In a distributed scenario, on which side are the listener’s methods invoked?
Answer: The listener is always invoked on the client side, which means that each client JVM has its own, independent statistics for the same bucket.
Question: Why is the listener invoked on the client side rather than the server side in a distributed scenario? What should I do if I need aggregated statistics across the whole cluster?
Answer: This is because of planned expansion to non-JVM backends such as Redis, MySQL, and PostgreSQL. It is not possible to serialize and invoke a listener on these non-Java backends, so it was decided to always invoke listeners on the client side, to avoid inconsistent behavior between different backends in the future. You can perform post-aggregation of monitoring statistics using features built into your monitoring database, or via a mediator (such as StatsD) between your application and the monitoring database.
2.3.3. Attaching a listener to a local bucket at build time
BucketListener listener = new MyListener();
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.withListener(listener)
.build()
2.3.4. Attaching a listener to a distributed bucket at build time
BucketListener listener = new MyListener();
Bucket bucket = proxyManager.builder()
.withListener(listener)
.build(key, configSupplier);
2.3.5. Attaching a listener to an async distributed bucket at build time
BucketListener listener = new MyListener();
AsyncBucketProxy bucket = proxyManager.asAsync().builder()
.withListener(listener)
.build(key, configSupplier);
2.3.6. Attaching a default listener at proxy-manager build time
You can configure a default listener when building a proxy manager. This listener is then used for every bucket that belongs to this proxy manager. Below is an example for Hazelcast; the approach is the same for other backends.
BucketListener listener = new MyListener();
// the listener will be attached to every bucket that belongs to this proxy manager
HazelcastLockBasedProxyManager proxyManager = Bucket4jHazelcast.entryProcessorBasedBuilder(map)
.defaultListener(listener)
.build();
2.3.7. Attaching a listener to a bucket at usage time
Sometimes the listener is not known at bucket build time, and you want to attach it later. This can happen, for example, when the same bucket is shared across multiple users, but you still need a dedicated listener per user.
In this case, you can build the bucket without a listener and attach one later via the toListenable method.
This method wraps the original bucket in a decorator that reports events to the given listener:
public void doSomethingProtected(User user, Bucket bucket) {
bucket = decorate(user, bucket);
if (bucket.tryConsume(1)) {
doSomething(user);
} else {
handleRateLimitError(user);
}
}
...
private Bucket decorate(User user, Bucket originalBucket) {
BucketListener listener = new BucketListener() {
@Override
public void onConsumed(long tokens) {
// log something related to the user, or increment a user-related metric
}
@Override
public void onRejected(long tokens) {
// log something related to the user, or increment a user-related metric
}
// ... other BucketListener methods omitted for brevity
}
return originalBucket.toListenable(listener);
}
2.3.8. Example of integration with Micrometer
io.github.bucket4j.SimpleBucketListener is a ready-to-use implementation of the io.github.bucket4j.BucketListener interface that simply counts events.
Below is an example of exposing its statistics via Micrometer:
private Bucket decorateBucketByStatListener(Bucket originalBucket, String bucketName) {
Iterable<Tag> tag = ImmutableList.of(new ImmutableTag("bucket", bucketName));
SimpleBucketListener stat = new SimpleBucketListener();
Metrics.gauge("bucket4j.consumed", tag, stat, SimpleBucketListener::getConsumed);
Metrics.gauge("bucket4j.rejected", tag, stat, SimpleBucketListener::getRejected);
Metrics.gauge("bucket4j.parkedNanos", tag, stat, SimpleBucketListener::getParkedNanos);
Metrics.gauge("bucket4j.interrupted", tag, stat, SimpleBucketListener::getInterrupted);
Metrics.gauge("bucket4j.delayedNanos", tag, stat, SimpleBucketListener::getDelayedNanos);
return originalBucket.toListenable(stat);
}
2.3.9. Example of integration with Dropwizard Metrics
Below is an example of exposing statistics via Dropwizard Metrics:
public static Bucket decorateBucketByStatListener(Bucket originalBucket, String bucketName, MetricRegistry registry) {
SimpleBucketListener stat = new SimpleBucketListener();
registry.register(bucketName + ".consumed", (Gauge<Long>) stat::getConsumed);
registry.register(bucketName + ".rejected", (Gauge<Long>) stat::getRejected);
registry.register(bucketName + ".parkedNanos", (Gauge<Long>) stat::getParkedNanos);
registry.register(bucketName + ".interrupted", (Gauge<Long>) stat::getInterrupted);
registry.register(bucketName + ".delayedNanos", (Gauge<Long>) stat::getDelayedNanos);
return originalBucket.toListenable(stat);
}
2.4. Verbose/Debug API
- Verbose API
-
is an API whose purpose is to attach low-level diagnostic information to the result of any interaction with a bucket. It provides the same functionality as the regular API, with one difference - the result of any method is always wrapped in a
VerboseResult. - VerboseResult
-
is a wrapper around an interaction result that also carries a snapshot of the bucket and its configuration, as they were at the moment of the interaction.
2.4.1. Verbose API entry points
The way to access the Verbose API is the same for every kind of bucket - just call the asVerbose() method:
// for io.github.bucket4j.Bucket
Bucket bucket = ...;
VerboseBucket verboseBucket = bucket.asVerbose();
VerboseSchedulingBucket verboseSchedulingBucket = bucket.asScheduler().asVerbose();
VerboseBlockingBucket verboseBlockingBucket = bucket.asBlocking().asVerbose();
// for io.github.bucket4j.distributed.AsyncBucketProxy
AsyncBucketProxy bucket = ...;
AsyncVerboseBucket verboseBucket = bucket.asVerbose();
VerboseSchedulingBucket verboseSchedulingBucket = bucket.asScheduler().asVerbose();
2.4.2. Principles of result decoration
-
A
voidreturn type is always wrapped asVerboseResult<Void>. -
A primitive result type such as
longorbooleanis always wrapped using the corresponding boxed type, for exampleVerboseResult<Boolean>. -
A non-primitive result type is always wrapped as-is, for example
VerboseResult<EstimationProbe>.
2.4.3. Example of Verbose API usage
VerboseResult<ConsumptionProbe> verboseResult = bucket.asVerbose().tryConsumeAndReturnRemaining(numberOfTokens);
BucketConfiguration bucketConfiguration = verboseResult.getConfiguration();
long capacity = Arrays.stream(bucketConfiguration.getBandwidths())
.mapToLong(Bandwidth::getCapacity)
.max().getAsLong();
response.addHeader("RateLimit-Limit", "" + capacity);
VerboseResult.Diagnostics diagnostics = verboseResult.getDiagnostics();
response.addHeader("RateLimit-Remaining", "" + diagnostics.getAvailableTokens());
response.addHeader("RateLimit-Reset", "" + TimeUnit.NANOSECONDS.toSeconds(diagnostics.calculateFullRefillingTime()));
ConsumptionProbe probe = verboseResult.getValue();
if (probe.isConsumed()) {
// the limit is not exceeded
filterChain.doFilter(servletRequest, servletResponse);
} else {
// limit is exceeded
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setStatus(429);
httpResponse.setContentType("text/plain");
httpResponse.getWriter().append("Too many requests");
}
2.5. On-the-fly configuration replacement
As mentioned in the definition of BucketConfiguration, a configuration is an immutable object.
You cannot add, remove, or change the limits of an already-created configuration. However, you can replace the configuration of a bucket by creating a new configuration instance and calling bucket.replaceConfiguration(newConfiguration, tokensInheritanceStrategy).
2.5.1. Why configuration replacement is not trivial
-
The first problem with configuration replacement is deciding how to carry over the available tokens from the bucket’s previous configuration to its new one. If you do not care about the previous bucket state, use TokensInheritanceStrategy.RESET. But the problem becomes tricky when previous consumption (not yet compensated by a refill) should still have an effect on the bucket under the new configuration. In that case, you need to choose between:
-
There is a second problem when you choose PROPORTIONALLY, AS_IS, or ADDITIVE and the bucket has more than one bandwidth. For example, how should
replaceConfigurationmatch up the old and new bandwidths in the example below?Bucket bucket = Bucket.builder() .addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1))) .addLimit(limit -> limit.capacity(10000).refillGreedy(10000, ofHours(1))) .build(); ... BucketConfiguration newConfiguration = BucketConfiguration.builder() .addLimit(limit -> limit.capacity(5000).refillGreedy(5000, ofHours(1))) .addLimit(limit -> limit.capacity(100).refillGreedy(100, ofSeconds(10))) .build(); bucket.replaceConfiguration(newConfiguration, TokensInheritanceStrategy.AS_IS);A naive strategy - copying tokens by bandwidth index - clearly does not work well here, because the result would depend entirely on the order in which bandwidths happen to be listed in the old and new configurations.
2.5.2. Taking control of the replacement process via bandwidth identifiers
Instead of relying on fragile positional matching, Bucket4j lets you keep control of this process by assigning identifiers to bandwidths, so that in the case of multiple bandwidths, the replacement code can copy available tokens by bandwidth ID. It is better to rewrite the code above as follows:
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)).id("technical-limit"))
.addLimit(limit -> limit.capacity(10000).refillGreedy(10000, ofHours(1)).id("business-limit"))
.build();
...
BucketConfiguration newConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofSeconds(10)).id("technical-limit"))
.addLimit(limit -> limit.capacity(5000).refillGreedy(5000, ofHours(1)).id("business-limit"))
.build();
bucket.replaceConfiguration(newConfiguration, TokensInheritanceStrategy.PROPORTIONALLY);
-
By default, a bandwidth has a null identifier.
-
A
nullidentifier is only considered equal to anothernullidentifier if there is exactly one bandwidth with anullidentifier in the bucket. -
If an identifier is specified for a bandwidth, it must be unique within the bucket - Bucket4j does not allow creating several bandwidths with the same ID.
2.5.3. TokensInheritanceStrategy explanation
TokensInheritanceStrategy specifies the rules for carrying over available tokens during the configuration-replacement process.
- RESET
-
Use this mode when you want to simply forget the previous bucket state.
RESETinstructs Bucket4j to erase all previous state. Using this strategy is equivalent to removing the bucket and creating it again with the new configuration.
- PROPORTIONALLY
-
Copies available tokens proportionally to the change in bandwidth capacity, using the following formula: newAvailableTokens = availableTokensBeforeReplacement * (newBandwidthCapacity / capacityBeforeReplacement)
PROPORTIONALLY strategy examples:-
Example 1: imagine a bandwidth created with
Bandwidth.builder().capacity(100).refillGreedy(10, ofMinutes(1)).build().At the moment of configuration replacement, there were 40 available tokens.
After replacing this bandwidth with
Bandwidth.builder().capacity(200).refillGreedy(10, ofMinutes(1)).build(), the 40 available tokens are multiplied by 2 (200/100), so after replacement we have 80 available tokens. -
Example 2: imagine a bandwidth created with
Bandwidth.builder().capacity(100).refillGreedy(10, ofMinutes(1)).build(). At the moment of configuration replacement, there were 40 available tokens. After replacing this bandwidth withBandwidth.builder().capacity(20).refillGreedy(10, ofMinutes(1)).build(), the 40 available tokens are multiplied by 0.2 (20/100), so after replacement we have 8 available tokens.
-
- AS_IS
-
Copies available tokens as-is, with one exception: if the number of available tokens is greater than the new capacity, it is reduced to the new capacity.
AS_IS strategy examples:-
Example 1: imagine a bandwidth created with
Bandwidth.builder().capacity(100).refillGreedy(10, ofMinutes(1)).build().At the moment of configuration replacement, there were 40 available tokens.
After replacing this bandwidth with
Bandwidth.builder().capacity(200).refillGreedy(10, ofMinutes(1)).build(), the 40 available tokens are simply copied, so after replacement we have 40 available tokens. -
Example 2: imagine a bandwidth created with
Bandwidth.builder().capacity(100).refillGreedy(10, ofMinutes(1)).build().At the moment of configuration replacement, there were 40 available tokens.
After replacing this bandwidth with
Bandwidth.builder().capacity(20).refillGreedy(10, ofMinutes(1)).build(), the 40 available tokens cannot be copied as-is because they exceed the new capacity, so available tokens are reduced to 20.
-
- ADDITIVE
-
Copies available tokens as-is, with one exception: if the new bandwidth capacity is greater than the old capacity, the available tokens are increased by the difference between the old and the new capacity.
The formula is:
newAvailableTokens = Math.min(availableTokensBeforeReplacement, newBandwidthCapacity) + Math.max(0, newBandwidthCapacity - capacityBeforeReplacement)ADDITIVE strategy examples:-
Example 1: imagine a bandwidth created with
Bandwidth.builder().capacity(100).refillGreedy(10, ofMinutes(1)).build().At the moment of configuration replacement, there were 40 available tokens.
After replacing this bandwidth with
Bandwidth.builder().capacity(200).refillGreedy(200, ofMinutes(1)).build(), the 40 available tokens are copied and increased by the difference between the old and new capacity, so after replacement we have 140 available tokens. -
Example 2: imagine a bandwidth created with
Bandwidth.builder().capacity(100).refillGreedy(10, ofMinutes(1)).build().At the moment of configuration replacement, there were 40 available tokens.
After replacing this bandwidth with
Bandwidth.builder().capacity(20).refillGreedy(10, ofMinutes(1)).build(), after replacement we have 20 available tokens. -
Example 3: imagine a bandwidth created with
Bandwidth.builder().capacity(100).refillGreedy(10, ofMinutes(1)).build().At the moment of configuration replacement, there were 10 available tokens.
After replacing this bandwidth with
Bandwidth.builder().capacity(100).refillGreedy(20, ofMinutes(1)).build(), after replacement we have 10 available tokens.
-
2.6. Generic production checklist
The considerations below apply to any solution based on the token-bucket or leaky-bucket algorithm, not just Bucket4j. Before going to production, make sure you understand, agree with, and have configured the following points.
2.6.1. Be wary of long periods
When you are using a token-bucket-based solution to throttle incoming requests, pay close attention to the throttling time window.
-
Given a bucket with a limit of 10,000 tokens per hour, per user.
-
A malicious attacker could send 9,999 requests in a very short period, for example within 10 seconds. That is roughly 1,000 requests per second, which could seriously impact your system.
-
A skilled attacker could stop at 9,999 requests per hour and repeat this every hour, making the attack effectively undetectable, because the configured limit is never actually reached.
To protect against this kind of attack, specify multiple limits, as shown below:
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(10_000).refillGreedy(10_000, ofHours(1)))
.addLimit(limit -> limit.capacity(20).refillGreedy(20, ofSeconds(1))) // the attacker can no longer reach 1000 RPS and overwhelm the service in a short burst
The number of limits configured per bucket has no impact on performance.
2.6.2. Be wary of short-timed bursts
The token bucket is an efficient algorithm with a low, fixed memory footprint - regardless of the incoming request rate (even millions of requests per second), a bucket consumes no more than 40 bytes (five longs). But this efficient memory footprint comes at a cost: a bandwidth limit is only guaranteed over a long period of time. In other words, short-timed bursts cannot be avoided.
-
Given a bucket with a limit of 100 tokens/min. We start with a full bucket, i.e. 100 tokens.
-
At
T1, 100 requests are made, so the bucket becomes empty. -
At
T1 + 1min, the bucket is full again because the tokens have fully regenerated, so we can immediately consume 100 more tokens. -
This means that between
T1andT1 + 1minwe consumed 200 tokens. Over a long time window there will never be more than 100 requests per minute on average, but as shown above, it is possible to burst up to twice the configured limit - here, 200 tokens within roughly a minute.
-
Do not use Bucket4j, or any other token-bucket-based solution, because the token-bucket algorithm was specifically designed for network traffic management devices, where short-lived traffic spikes are the normal case. Trying to avoid spikes altogether goes against the nature of the token-bucket algorithm.
-
Since the size of a burst always equals the capacity, you can reduce the capacity and the refill speed together. For example, if you have a strict requirement of
100 tokens/60 seconds, configure the bucket ascapacity=50 tokens, refill=50 tokens/60 seconds. Note that this approach has the following drawbacks: — You can no longer consume more tokens in a single request than the (now smaller) capacity - in the example above, before reducing capacity you could consume up to 100 tokens in a single request; after reducing it, you can consume at most 50. — Reducing the refill speed leads to under-consumption over long periods. In the worst case (starting with a full bucket and draining it immediately after every refill), a refill of50 tokens/60 secondslets you consume at most 3,050 tokens per hour, instead of the 6,100 tokens per hour possible before reducing the refill speed. — In short, you trade under-consumption in exchange for eliminating the risk of overconsumption.
2.7. Technical limitations
To provide the best possible precision, Bucket4j relies on integer arithmetic wherever possible, so every internal calculation is bounded by Long.MAX_VALUE. The library imposes a few limits, described below, to guarantee that these calculations never overflow.
2.7.1. Maximum refill rate
The maximum refill rate is limited to 1 token / 1 nanosecond. The examples below will raise an exception:
Bandwidth.builder().capacity(100).refillGreedy(2, ofNanos(1));
Bandwidth.builder().capacity(10_000).refillGreedy(1_001, ofNanos(1_000));
Bandwidth.builder().capacity(1_000_000).refillGreedy(1_000_001, ofMillis(1));
2.7.2. Limitation on refill period
Bucket4j represents time intervals as a 64-bit number of nanoseconds, so the longest possible refill period is:
Duration.ofNanos(Long.MAX_VALUE);
Any attempt to specify a period longer than this limit fails with an exception. For example, the code below fails:
Bandwidth.builder(limit -> limit.capacity(...).refillGreedy(42, Duration.ofMinutes(153722867280912930));
Exception in thread "main" java.lang.ArithmeticException: long overflow
at java.lang.Math.multiplyExact(Math.java:892)
at java.time.Duration.toNanos(Duration.java:1186)
...
3. Distributed facilities
3.1. Concepts
Using Bucket4j in a cluster of JVMs introduces a few new entities in addition to the ones already described in basic concepts.
3.1.1. ProxyManager
ProxyManager is the main extension point that connects Bucket4j to a particular storage technology (a JCache-compliant grid, Redis, a relational database, MongoDB, Couchbase, etc).
A ProxyManager is typically built once per RDBMS table, grid cache, or similarly isolated part of the external storage, and buckets are distinguished from each other within it by a primary key.
Each backend module provides its own way to build a ProxyManager (see the documentation page for your specific backend), but once built, it is used the same way everywhere:
ProxyManager<String> proxyManager = ...; // built in a backend-specific way
Supplier<BucketConfiguration> configurationSupplier = () -> BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
.build();
BucketProxy bucket = proxyManager.getProxy("42", configurationSupplier);
The configurationSupplier is invoked lazily, and only once - the first time a bucket with the given key is created in the storage. On every subsequent call for the same key, the persisted configuration is reused and the supplier is not invoked again.
If you need to configure something that cannot be expressed through getProxy (for example Implicit configuration replacement, a custom RecoveryStrategy, a BucketListener, or Client-side configuration such as request timeouts and CAS retry limits), use proxyManager.builder()…build(key, configurationSupplier) instead.
3.1.2. BucketProxy
BucketProxy is a lightweight handle to a bucket whose actual state is stored outside the current JVM - in a grid, a database, or a key-value store. It extends the regular Bucket interface, so you interact with it exactly as you would with a local bucket (tryConsume, asBlocking(), asVerbose(), and so on); the difference is purely in where the state lives and how it is synchronized.
Unless you explicitly attached a request-optimization strategy via withOptimization(…), a BucketProxy is a cheap object: it holds no state of its own, and you do not need to cache or reuse the instance returned by getProxy/build. Feel free to build, use, and discard one for every request.
AsyncBucketProxy is the asynchronous equivalent of BucketProxy; it is obtained from an AsyncProxyManager (see Asynchronous API) and every method returns a CompletableFuture instead of blocking the calling thread.
3.1.3. Frequently asked questions
Question: Is it safe for two nodes to try to create a bucket with the same key at the same time? Will the second node overwrite (and effectively reset) the bucket created by the first one?
Answer: No, this is safe. Bucket4j never blindly overwrites an existing bucket. Bucket creation always goes through an atomic "create if absent" operation specific to the backend
(for example, putIfAbsent for JCache-compliant grids, a Lua-scripted SETNX-like check for Redis, or an INSERT that is allowed to fail for SQL databases). If two nodes race to create the same bucket, only one of them wins, and the other transparently starts working with the bucket that was created first.
Question: Does ProxyManager keep buckets in memory on the client side? Could having many buckets cause an OutOfMemoryError in my application?
Answer: No. ProxyManager does not cache or retain anything about the buckets it hands out - all state lives in the external storage, outside your JVM.
Think of the object returned by ProxyManager#getProxy as a very cheap pointer to data that lives elsewhere, not as a container for that data.
Because of this, the number of buckets you create through a ProxyManager has no bearing on your application’s memory consumption; any growth in memory usage will show up in the external storage, not in your JVM.
Question: What happens if a bucket’s state is lost in the storage - because of a split-brain, a human mistake, or a bug introduced by the storage vendor?
Answer: By default, ProxyManager detects this situation and reconstructs the bucket from scratch using the configurationSupplier you provided when the bucket was created (this is the RecoveryStrategy.RECONSTRUCT strategy, which favors availability over consistency).
The reconstructed bucket remembers nothing about tokens consumed before the loss, so the configured limit can technically be exceeded across this kind of storage failure.
If you would rather fail loudly than silently reset a bucket, configure RemoteBucketBuilder#withRecoveryStrategy(RecoveryStrategy.THROW_BUCKET_NOT_FOUND_EXCEPTION), which throws BucketNotFoundException instead.
Question: Should I always go through ProxyManager, or can I keep a BucketProxy reference around and reuse it?
Answer: It depends on your access pattern.
-
If you are dealing with a potentially large and unpredictable number of buckets (for example, one bucket per user or per IP address), it is best to call
getProxy/buildeach time you need the bucket, and letProxyManagercreate a fresh, cheap proxy every time. This protects you from common pitfalls, like accidentally keeping a huge number of proxy objects alive on the client side. -
If you are dealing with one or a few buckets that are well known at development time (for example, a small number of global rate limits for your service), it is fine to build the
BucketProxyonce and keep a reference to it for the lifetime of your application - as long as you have not attached a request-optimization strategy viawithOptimization(…)that would make the proxy stateful.
3.2. Production checklist for distributed systems
Before using Bucket4j in a clustered scenario, make sure you understand, agree with, and have configured the following points.
In a distributed system, requests inevitably cross the boundary of the current JVM and require communication over the network. Because the network is unreliable, failures cannot be avoided. You should expect this and be ready to receive unchecked exceptions when interacting with a distributed bucket. It is your responsibility to handle (or deliberately ignore) such exceptions:
-
If you do not want to fail business transactions when the grid responsible for throttling goes down, simply log the exception and continue the business transaction without throttling.
-
If you want your business transaction to fail when the grid responsible for throttling goes down, simply rethrow the exception, or do not catch it at all.
If a bucket’s state needs to survive a restart or crash of the grid node that holds it, you need to configure backups yourself, in a way specific to your grid vendor. For example, see how to configure backups for Apache Ignite.
In multi-tenant scenarios, such as a bucket per user or a bucket per IP address, the number of buckets in the cache keeps growing, because a new bucket is created every time a new key is encountered.
To avoid exhausting the memory of your cluster, you need to configure the following: * Maximum cache size (in bytes) - it is preferable to lose some bucket data than to lose the whole cluster to an out-of-memory error. * Expiration policy - Bucket4j provides a way to configure flexible per-entry expiration for most integrations (Apache Ignite is the exception). Consult the Bucket4j documentation for your particular backend to find out how to configure its expiration policy.
Bucket4j does not provide any special settings for HA, because all it does is invoke entry processors (or their equivalent) on the underlying cache. Instead, Bucket4j relies on you to configure the cache with the redundancy and high-availability parameters it needs.
Years of experience with distributed systems have taught the author that high availability does not come for free. You need to test and verify that your system remains available under failure - this cannot be guaranteed by this or any other library. If you do not plan for failure, your system will most certainly go down when it happens.
3.3. Integrations with in-memory grids
3.3.1. JCache integration
Bucket4j supports any grid solution compatible with the JCache API (JSR 107) specification.
|
Note
|
Do not forget to read Distributed usage checklist before using Bucket4j over a JCache cluster. |
To use the JCache extension you need to add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-jcache</artifactId>
<version>8.20.0</version>
</dependency>
JCache expects javax.cache:cache-api to be provided by your application. Do not forget to add the following dependency:
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>${jcache.version}</version>
</dependency>
Example 1 - limiting access to an HTTP server by IP address
Imagine that you are developing a Servlet-based web application and want to limit access on a per-IP basis. You want to apply the same limit to every IP - 30 requests per minute.
A ServletFilter is the obvious place to check the limit:
public class IpThrottlingFilter implements javax.servlet.Filter {
private static final BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(30).refillGreedy(30, ofMinutes(1)))
.build();
// cache for storing token buckets, where IP is the key.
@Inject
private javax.cache.Cache<String, byte[]> cache;
private ProxyManager<String> buckets;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// init bucket registry
buckets = Bucket4jJCache
.entryProcessorBasedBuilder(cache)
// setup optional parameters if necessary
.build();
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
String ip = IpHelper.getIpFromRequest(httpRequest);
// acquire a cheap proxy to the bucket
Bucket bucket = buckets.getProxy(ip, () -> configuration);
// tryConsume returns false immediately if no tokens are available in the bucket
if (bucket.tryConsume(1)) {
// the limit is not exceeded
filterChain.doFilter(servletRequest, servletResponse);
} else {
// limit is exceeded
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setContentType("text/plain");
httpResponse.setStatus(429);
httpResponse.getWriter().append("Too many requests");
}
}
}
Example 2 - limiting access to a service according to per-contract agreements
Imagine that you provide a paid language translation service over HTTP. Each user has a unique agreement that differs from every other user’s. The details of each agreement are stored in a relational database and take a significant time to fetch (for example, 100ms). The previous example would not work well here, because the time needed to create/fetch the bucket configuration from the database would be 100 times slower than the limit check itself. Bucket4j solves this problem with lazy configuration suppliers, which are only called if the bucket has not already been stored in the grid, making it possible to read the agreement from the database just once per user.
public class IpThrottlingFilter implements javax.servlet.Filter {
// service that provides per-user limits
@Inject
private LimitProvider limitProvider;
// cache for storing token buckets, where the user id is the key.
@Inject
private javax.cache.Cache<String, byte[]> cache;
private ProxyManager<String> buckets;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// init bucket registry
buckets = Bucket4jJCache
.entryProcessorBasedBuilder(cache)
// setup optional parameters if necessary
.build();
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
String userId = AuthenticationHelper.getUserIdFromRequest(httpRequest);
// prepare a configuration supplier that will be called (on the first interaction with the proxy) if the bucket has not been saved yet
Supplier<BucketConfiguration> configurationLazySupplier = getConfigSupplierForUser(userId);
// acquire a cheap proxy to the bucket
Bucket bucket = buckets.getProxy(userId, configurationLazySupplier);
// tryConsume returns false immediately if no tokens are available in the bucket
if (bucket.tryConsume(1)) {
// the limit is not exceeded
filterChain.doFilter(servletRequest, servletResponse);
} else {
// limit is exceeded
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setContentType("text/plain");
httpResponse.setStatus(429);
httpResponse.getWriter().append("Too many requests");
}
}
private Supplier<BucketConfiguration> getConfigSupplierForUser(String userId) {
return () -> {
long translationsPerDay = limitProvider.readPerDayLimitFromAgreementsDatabase(userId);
return BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(translationsPerDay).refillGreedy(1_000, ofDays(1)))
.build();
};
}
}
Why JCache is not enough for modern stacks, and why dedicated modules for Infinispan, Hazelcast, Coherence, and Ignite were introduced since 3.0
Asynchronous processing is very important for high-throughput applications, but the JCache specification does not define an asynchronous API - two early attempts to bring this kind of functionality into the spec (307, 312) failed for lack of consensus.
Adding asynchronous support for any other JCache provider not in this list should be a fairly easy exercise - feel free to submit a pull request for your favorite JCache provider.
Verifying compatibility with a particular JCache provider is your responsibility
|
Important
|
Keep in mind that there are many non-certified implementations of the JCache specification on the market. Many of them try to increase their popularity by declaring support for the JCache API, while only implementing the API surface and ignoring its semantics. Avoid using Bucket4j with this kind of library. |
Bucket4j is only compatible with implementations that follow the JCache specification’s rules, especially those related to EntryProcessor execution. Oracle Coherence, Apache Ignite, and Hazelcast are good examples of safe JCache implementations.
|
Important
|
Since it is impossible to test every JCache provider, you need to test yours yourself. |
Run the following code to check whether your JCache implementation provides proper isolation for EntryProcessor:
import javax.cache.Cache;
import javax.cache.processor.EntryProcessor;
import java.util.concurrent.CountDownLatch;
import java.io.Serializable;
public class CompatibilityTest {
final Cache<String, Integer> cache;
public CompatibilityTest(Cache<String, Integer> cache) {
this.cache = cache;
}
public void test() throws InterruptedException {
String key = "42";
int threads = 4;
int iterations = 1000;
cache.put(key, 0);
CountDownLatch latch = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(() -> {
try {
for (int j = 0; j < iterations; j++) {
EntryProcessor<String, Integer, Void> processor = (EntryProcessor<String, Integer, Void> & Serializable) (mutableEntry, objects) -> {
int value = mutableEntry.getValue();
mutableEntry.setValue(value + 1);
return null;
};
cache.invoke(key, processor);
}
} finally {
latch.countDown();
}
}).start();
}
latch.await();
int value = cache.get(key);
if (value == threads * iterations) {
System.out.println("Implementation which you use is compatible with Bucket4j");
} else {
String msg = "Implementation which you use is not compatible with Bucket4j";
msg += ", " + (threads * iterations - value) + " writes are missed";
throw new IllegalStateException(msg);
}
}
}
The check performs 4000 concurrent increments of an integer and verifies that no update was missed. If the check passes, your JCache provider is compatible with Bucket4j and throttling will work correctly in a distributed, concurrent environment. If the check fails, reach out to the JCache provider’s team and ask why its implementation loses writes.
3.3.2. Hazelcast integration
Dependencies
To use the Bucket4j extension with a current version of Hazelcast, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-hazelcast</artifactId>
<version>8.20.0</version>
</dependency>
If you are using a legacy Hazelcast 4.x release, add this dependency instead:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-hazelcast-4</artifactId>
<version>8.20.0</version>
</dependency>
General compatibility matrix principles
-
Bucket4j’s authors do not continuously monitor new Hazelcast releases, so there can be periods when no Bucket4j version is compatible with a newly released Hazelcast version. Log an issue in the bug tracker if you hit this - adding support for a new Hazelcast version is usually an easy fix.
-
Integrations with legacy Hazelcast versions are not removed without a clear reason. Even if you work at a large enterprise that does not update its infrastructure often, you still get Bucket4j’s new features on legacy Hazelcast releases.
Example of Bucket instantiation
IMap<K, byte[]> map = ...;
private static final HazelcastProxyManager<K> proxyManager = Bucket4jHazelcast
.entryProcessorBasedBuilder(map)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
Configuring flexible per-entry expiration
It is possible to configure precise expiration for bucket entries in the cache, so that data related to a bucket is not stored for longer than needed to refill its consumed tokens.
IMap<K, byte[]> map = ...;
Duration evictionJitter = Duration.ofSeconds(15);
ExpirationAfterWriteStrategy expiration = ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(evictionJitter);
private static final HazelcastProxyManager<K> proxyManager = Bucket4jHazelcast
.entryProcessorBasedBuilder(map)
.expirationAfterWrite(expiration)
// setup optional parameters if necessary
.build();
How should you choose the eviction jitter? Zero means immediate eviction after refill; however, it is better to avoid too small a jitter, because recreating a bucket after expiration requires an extra network hop. Configuring at least a few seconds of jitter helps avoid recreating buckets too frequently.
Configuring custom serialization for Bucket4j library classes
If you configure nothing, Bucket4j library classes are serialized using plain Java serialization by default, which can be rather slow and should generally be avoided.
Bucket4j provides custom serializers for all library classes that can be transferred over the network.
To let Hazelcast know about the fast serializers, register them programmatically in the serialization config:
import com.hazelcast.config.Config;
import com.hazelcast.config.SerializationConfig;
import com.hazelcast.config.SerializerConfig;
import io.github.bucket4j.grid.hazelcast.serialization.HazelcastSerializer;
...
Config config = ...
SerializationConfig serializationConfig = config.getSerializationConfig();
// the starting type ID number for Bucket4j classes.
// you are free to choose any unused ID, but be aware that Bucket4j currently uses 2 types
// and may use more in the future, so leave enough empty space after baseTypeIdNumber
int baseTypeIdNumber = 10000;
HazelcastProxyManager.addCustomSerializers(serializationConfig, baseTypeIdNumber);
Configuring custom serialization for a standalone Hazelcast cluster
If the Hazelcast cluster runs standalone, outside your application - started directly from its own jar or hosted by third-party software - you are not in a position to register the custom serializers programmatically.
To make such a cluster aware of the custom serialization, three actions are required:
-
Add the Bucket4j jars (
bucket4j-coreandbucket4j-hazelcast) to the classpath of every node in the Hazelcast cluster. -
Declare
typeIdBasevia an OS environment variable or a Java system property; in both cases the name isbucket4j.hazelcast.serializer.type_id_base. The value provided on the Hazelcast server side must match the one used programmatically in your Java code on the Hazelcast client side. -
Configure the custom serializers in the Hazelcast server configuration file - see the following snippet for reference:
# ----- Hazelcast serialization configuration -----
serialization:
serializers:
- type-class: io.github.bucket4j.grid.hazelcast.HazelcastEntryProcessor
class-name: io.github.bucket4j.grid.hazelcast.serialization.HazelcastEntryProcessorSerializer
- type-class: io.github.bucket4j.grid.hazelcast.SimpleBackupProcessor
class-name: io.github.bucket4j.grid.hazelcast.serialization.SimpleBackupProcessorSerializer
- type-class: io.github.bucket4j.grid.hazelcast.HazelcastOffloadableEntryProcessor
class-name: io.github.bucket4j.grid.hazelcast.serialization.HazelcastOffloadableEntryProcessorSerializer
Support for externally managed Hazelcast without classpath access
bucket4j-hazelcast requires putting the Bucket4j jars on the classpath of every node in the Hazelcast cluster.
Sometimes you have no control over the classpath, because the Hazelcast cluster is externally managed (a PaaS scenario).
In such cases, HazelcastProxyManager cannot be used, because it is implemented on top of the EntryProcessor functionality.
- HazelcastLockBasedProxyManager
-
implemented on top of the IMap methods
lock,get,put,unlock. This implementation always requires 4 network hops per rate-limit check. - HazelcastCompareAndSwapBasedProxyManager
-
implemented on top of the IMap methods
get,replace,putIfAbsent. This implementation requires 2 network hops when there is no contention, but the number of hops is unpredictable under high contention on the key.
-
HazelcastLockBasedProxyManagerdoes not provide an async API, because the IMap API lackslockAsyncandunlockAsyncmethods. -
HazelcastCompareAndSwapBasedProxyManagerdoes not provide an async API, because the IMap API lacksreplaceAsyncandputIfAbsentAsyncmethods.
If you would like HazelcastLockBasedProxyManager and HazelcastCompareAndSwapBasedProxyManager to support an async API, ask the Hazelcast maintainers to add the missing APIs mentioned above.
Known issues related to Docker and/or Spring Boot
-
#186 HazelcastEntryProcessor class not found - check file permissions inside your image.
-
#162 HazelcastSerializationException with Hazelcast 4.2 - properly set up the classloader in your Hazelcast client configuration.
3.3.3. Apache Ignite integration
Before using the bucket4j-ignite module, please read the bucket4j-jcache documentation,
because bucket4j-ignite is just a follow-up to bucket4j-jcache.
Bucket4j supports the Ignite thin client as well as regular (thick client) deployment scenarios.
Question: Bucket4j has supported JCache since version 1.2. Why was direct support for Apache Ignite needed?
Answer: Because the JCache API (JSR 107) does not specify an asynchronous API,
developing the dedicated bucket4j-ignite module was the only way to provide asynchronous support to users who use Bucket4j together with Apache Ignite.
Question: Should I migrate from bucket4j-jcache to bucket4j-ignite if I do not need an asynchronous API?
Answer: No, you do not need to migrate to bucket4j-ignite in that case.
Dependencies
To use the bucket4j-ignite extension, add the following dependency:
<!-- For Java 17 -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-ignite</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation via IgniteProxyManager
org.apache.ignite.IgniteCache<K, byte[]> cache = ...;
private static final IgniteProxyManager<K> proxyManager = Bucket4jIgnite.thickClient()
.entryProcessorBasedBuilder(cache)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
|
Important
|
IgniteProxyManager requires every node in the cluster to have the Bucket4j jars on its classpath.
|
Example of Bucket instantiation via the thin client
org.apache.ignite.client.ClientCache<K, byte[]> cache = ...;
org.apache.ignite.client.ClientCompute clientCompute = ...;
private static final IgniteThinClientProxyManager<K> proxyManager = Bucket4jIgnite.thinClient()
.clientComputeBasedBuilder(cache, clientCompute)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
|
Important
|
IgniteThinClientProxyManager requires every node in the cluster to have the Bucket4j jars on its classpath.
|
Example of Bucket instantiation via the thin client and IgniteThinClientCasBasedProxyManager
org.apache.ignite.client.ClientCache<K, java.nio.ByteBuffer> cache = ...;
private static final IgniteThinClientCasBasedProxyManager<K> proxyManager = Bucket4jIgnite.thinClient()
.casBasedBuilder(cache)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
|
Important
|
unlike the other two options, IgniteThinClientCasBasedProxyManager does not require the Bucket4j jars to be present on every cluster node, but it operates with higher latency. Choose it over IgniteThinClientProxyManager only if you have no control over the cluster’s classpath.
|
Notes about the expiration policy
Unlike Infinispan, Coherence, and Hazelcast, Ignite does not provide an API for configuring per-entry expiration. So there is currently only one option - configure expiration at the cache level, for example, as described here. If this is not acceptable, switch to one of the other in-memory grids mentioned above.
3.3.4. Infinispan integration
Dependencies
To use bucket4j-infinispan with Infinispan 9.x/10.x, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-infinispan</artifactId>
<version>8.20.0</version>
</dependency>
General compatibility matrix principles
-
Bucket4j’s authors do not continuously monitor new Infinispan releases, so there can be periods when no Bucket4j version is compatible with a newly released Infinispan version. Log an issue in the bug tracker if you hit this - adding support for a new Infinispan version is usually an easy fix.
-
Integrations with legacy Infinispan versions are not removed without a clear reason. Even if you work at a large enterprise that does not update its infrastructure often, you still get Bucket4j’s new features on legacy Infinispan releases.
Special notes for Infinispan 10.0+
As mentioned in the Infinispan Marshalling documentation, since release 10.0.0 Infinispan no longer allows deserialization of custom payloads into Java classes. If you do not configure serialization (as described below), any attempt to use Bucket4j with a recent Infinispan release will fail with an error like this:
Jan 02, 2020 4:57:56 PM org.infinispan.marshall.persistence.impl.PersistenceMarshallerImpl objectToBuffer
WARN: ISPN000559: Cannot marshall 'class io.github.bucket4j.grid.infinispan.InfinispanProcessor'
java.lang.IllegalArgumentException: No marshaller registered for Java type io.github.bucket4j.grid.infinispan.SerializableFunctionAdapter
at org.infinispan.protostream.impl.SerializationContextImpl.getMarshallerDelegate(SerializationContextImpl.java:279)
at org.infinispan.protostream.WrappedMessage.writeMessage(WrappedMessage.java:240)
at org.infinispan.protostream.ProtobufUtil.toWrappedStream(ProtobufUtil.java:196)
There are three ways to solve this problem:
* Configure JBoss Marshalling instead of the default ProtoStream marshaller, as described here.
* Configure the Java Serialization Marshaller instead of the default ProtoStream marshaller, as described here.
Do not forget to add the io.github.bucket4j.* regexp to the allowlist if you choose this option.
* Or (recommended) simply register the Bucket4j serialization context initializer in the serialization configuration.
This can be done either programmatically or declaratively:
import io.github.bucket4j.grid.infinispan.serialization.Bucket4jProtobufContextInitializer;
import org.infinispan.configuration.global.GlobalConfigurationBuilder;
...
GlobalConfigurationBuilder builder = new GlobalConfigurationBuilder();
builder.serialization().addContextInitializer(new Bucket4jProtobufContextInitializer());
<serialization>
<context-initializer class="io.github.bucket4j.grid.infinispan.serialization.Bucket4jProtobufContextInitializer"/>
</serialization>
That’s it - registering Bucket4jProtobufContextInitializer in either way is enough to make Bucket4j compatible with the ProtoStream marshaller. You do not need to worry about *.proto files, annotations, or allowlists; all the necessary Protobuf configuration is generated by Bucket4jProtobufContextInitializer and registered on the fly.
Example of Bucket instantiation for EmbeddedCacheManager
org.infinispan.functional.FunctionalMap.ReadWriteMap<K, byte[]> map = ...;
private static final InfinispanProxyManager<K> proxyManager = Bucket4jInfinispan
.entryProcessorBasedBuilder(map)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
Example of Bucket instantiation for RemoteCacheManager (Hot Rod client)
org.infinispan.client.hotrod.RemoteCache<K, byte[]> remoteCache = ...;
private static final InfinispanProxyManager<K> proxyManager = Bucket4jInfinispan
.hotrodClientBasedBuilder(remoteCache)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
3.3.5. Oracle Coherence integration
Dependencies
To use the bucket4j-coherence extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-coherence</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation
com.tangosol.net.NamedCache<K, byte[]> cache = ...;
private static final CoherenceProxyManager<K> proxyManager = Bucket4jCoherence
.entryProcessorBasedBuilder(cache)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
Configuring POF serialization for Bucket4j library classes
If you configure nothing, Bucket4j library classes are serialized using plain Java serialization by default, which can be rather slow and should generally be avoided.
Bucket4j provides custom POF serializers for all library classes that can be transferred over the network.
To let Coherence know about the POF serializers, register the serializer in the POF configuration file:
io.github.bucket4j.grid.coherence.pof.CoherenceEntryProcessorPofSerializer for class io.github.bucket4j.grid.coherence.CoherenceProcessor
<pof-config xmlns="http://xmlns.oracle.com/coherence/coherence-pof-config"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.oracle.com/coherence/coherence-pof-config coherence-pof-config.xsd">
<user-type-list>
<!-- Include default Coherence types -->
<include>coherence-pof-config.xml</include>
<!-- Define serializers for Bucket4j classes -->
<user-type>
<type-id>1001</type-id>
<class-name>io.github.bucket4j.grid.coherence.CoherenceProcessor</class-name>
<serializer>
<class-name>io.github.bucket4j.grid.coherence.pof.CoherenceEntryProcessorPofSerializer</class-name>
</serializer>
</user-type>
</user-type-list>
</pof-config>
Double-check with official Oracle Coherence documentation in case of any questions related to Portable Object Format.
3.3.6. Apache Geode (GemFire) integration
Dependencies
To use the bucket4j-geode extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-geode</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation
org.apache.geode.cache.Region<K, byte[]> region = ...;
private static final GeodeProxyManager<K> proxyManager = Bucket4jGeode
.compareAndSwapBasedBuilder(region)
// setup optional parameters if necessary
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
|
Note
|
Unlike bucket4j-coherence and the thick-client flavor of bucket4j-ignite, GeodeProxyManager does not
ship any Bucket4j class to the Geode members - see below for why - so there is no need to put the Bucket4j jars
on the classpath of the members that host the region; only the client-side application needs the dependency.
|
Why Bucket4j-Geode is compare-and-swap based, and not entry-processor based (unlike Coherence and Ignite)
Coherence’s NamedCache.invoke() and Ignite’s IgniteCache.invoke() both provide a genuine per-key atomic
read-modify-write guarantee at the platform level: the entry-processor callback is executed exactly once,
holding an exclusive lock on the targeted key for the whole callback duration.
Geode has no equivalent primitive for a PARTITION region. FunctionService.onRegion(region).withFilter(…)
only routes the function to the member that owns the key’s bucket - it does not serialize concurrent
invocations that target the same key, so two functions racing on the same key can both read the same "old"
state and then independently overwrite it, silently losing one of the updates. An early prototype of this
module was built on top of FunctionService and reliably failed the Bucket4j TCK’s concurrency tests
(over-consumption of tokens, lost bucket initializations) for exactly this reason.
GeodeProxyManager therefore extends AbstractCompareAndSwapBasedProxyManager and performs the
read-modify-write cycle from the client side, guarding the write with a Geode
cache transaction:
the current value of the key is re-checked once the transaction has started, and CommitConflictException
on commit reveals that another member concurrently changed the same entry, so the Bucket4j retry loop simply
tries again. Note that Region.replace(key, oldValue, newValue) (inherited from ConcurrentMap) was
deliberately not used for this purpose: it compares the old value using byte[].equals(), which is an
identity comparison, and Geode is free to hand back a freshly deserialized array on every get() - so the
comparison can spuriously fail (or spuriously succeed, for two logically-different-but-never-fetched values)
depending on internal storage details that are not part of Geode’s public contract.
Function-based alternative: colocating the retry loop with the data
GeodeProxyManager’s compare-and-swap loop runs on the client: every losing racer’s retry is a fresh
`get() followed by a fresh transactional put()/commit(), each a full client-server round-trip. Under
heavy contention on the same key, this means the number of round-trips grows with the number of retries.
Bucket4jGeode.functionBasedBuilder(region) avoids this by dispatching the whole request to a Geode
Function (GeodeBucketFunction) that runs colocated with the data, on the member owning the key’s
bucket. The function performs the exact same compare-and-swap-via-transaction loop described above, but
entirely on the server: a losing racer retries locally against the freshly committed state instead of
making another round-trip back to the client. The client only pays for one round-trip per call, regardless
of how much local contention that call has to resolve on the server.
org.apache.geode.cache.Region<K, byte[]> region = ...;
private static final GeodeFunctionProxyManager<K> proxyManager = Bucket4jGeode
.functionBasedBuilder(region)
// setup optional parameters if necessary
.build();
This comes at a deployment cost that GeodeProxyManager does not have: GeodeFunctionProxyManager ships
the GeodeBucketFunction instance to the server on every call (via Execution.execute(Function), which
does not require pre-registration through FunctionService.registerFunction), so the Bucket4j jar must be
present on the classpath of every Geode member that can host the region - the same requirement
bucket4j-coherence and the thick-client flavor of bucket4j-ignite already have. Choose
compareAndSwapBasedBuilder if only the client application can depend on Bucket4j; choose
functionBasedBuilder if the servers can also carry the dependency and contention on individual keys is
expected to be significant.
GeodeFunctionProxyManager overrides Function.isHA() to return false. Geode’s default (true) would
let it transparently re-run the whole function on another member if the result never reached the caller -
for example, if the owning member dies right after commit() succeeds but before the result is delivered.
Since the function performs a non-idempotent bucket mutation, a silent automatic re-execution in that window
would risk double-applying an already-committed change. Disabling isHA() turns that unavoidable ambiguity
into a visible exception to the caller instead, the same failure mode GeodeProxyManager already has for a
lost acknowledgement.
Why there is no asynchronous API yet
Every other in-memory-grid integration that supports asynchronous mode (Hazelcast, Ignite, Coherence, Infinispan)
does so because the underlying client exposes a CompletableFuture-returning (or otherwise non-blocking)
counterpart to the synchronous call that Bucket4j needs.
Geode’s client API does not offer such a counterpart for the operations this module relies on:
-
Region(both for a peerCacheand for aClientCache) only exposes blockingget/put, there is nogetAsync/putAsync. -
CacheTransactionManageris likewise fully synchronous -begin()/commit()/rollback()all block the calling thread, there is no asynchronous transaction API to compose a non-blocking read-modify-write cycle on top of. -
Geode’s own asynchronous story instead centers on
AsyncEventListener/AsyncEventQueue, which are fire-and-forget, one-way notifications used for write-behind gateways - they have no request/response shape and cannot be adapted into aCompletableFuture<CommandResult<T>>the wayBucket4jneeds.
It is technically possible to fake asynchronicity by submitting the whole compare-and-swap cycle to an
Executor and wrapping the result in a CompletableFuture (this is what Bucket4j's own
ExecutionStrategy.background(Executor) option already lets any caller opt into, for any backend, entirely
on the client side). But that only moves blocking I/O to another thread; it does not give callers the
throughput benefit a real non-blocking network client provides. Since baking a look-alike async API into
GeodeProxyManager itself would be a misleading half-measure rather than actual asynchronous support,
GeodeProxyManager.isAsyncModeSupported() returns false, and the module currently only advertises the
synchronous API - users who need to run calls off the calling thread can still do so explicitly via
ExecutionStrategy.background(…). This may be revisited if a future Geode client release adds genuine
non-blocking primitives.
3.4. Bucket4j-Redis
Bucket4j provides integration with five Redis libraries:
| Library | Async supported | Redis cluster supported |
|---|---|---|
|
Yes |
Yes |
|
Yes |
Yes |
|
Yes |
Yes |
|
No |
Yes |
|
Yes |
Yes |
|
Important
|
For all libraries mentioned above, concurrent access to Redis is handled via the Compare&Swap pattern; this could be improved in the future by switching to Lua stored procedures. |
3.4.1. How Compare&Swap works in Redis
Bucket4j uses Lua scripts executed atomically via Redis EVAL command to implement the Compare&Swap pattern. The core logic is as follows:
if redis.call('get', KEYS[1]) == ARGV[1] then
redis.call('psetex', KEYS[1], ARGV[3], ARGV[2])
return 1
else
return 0
end
Where:
-
KEYS[1]— The Redis key holding the serialized bucket state -
ARGV[1]— The previously read bucket state (expected value for comparison) -
ARGV[2]— The new bucket state to set if comparison succeeds -
ARGV[3]— TTL in milliseconds (used withpsetex; omitted when expiration is not configured)
The operation flow:
-
Compare: Read the current value at
KEYS[1]and check if it matches the expected valueARGV[1]. -
Swap: If values match, atomically set the new value
ARGV[2]with an optional TTL and return1(success). -
Failure: If values don’t match (another process modified the bucket state between read and update), return
0. Bucket4j will then re-read the current state and retry the operation.
Because the entire Lua script is executed atomically by Redis, no other command can run between the compare and swap steps, which guarantees consistency without requiring distributed locks.
For the initial bucket creation, a separate SET NX (set-if-not-exists) script is used instead.
|
Tip
|
To configure retry limits, backoff delays, and timeouts for CAS operations, see Client-side configuration. |
|
Note
|
The bucket4j_jdk17-redis artifact is an aggregator module and should not be used as an application dependency. Use bucket4j_jdk17-redis-common together with a concrete Redis client module, such as bucket4j_jdk17-lettuce, bucket4j_jdk17-redisson, bucket4j_jdk17-jedis, bucket4j_jdk17-vertx, or bucket4j_jdk17-glide.
|
3.4.2. Lettuce integration
Dependencies
To use the bucket4j-lettuce extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-redis-common</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-lettuce</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation via LettuceBasedProxyManager
StatefulRedisConnection<K, byte[]> connection = ...;
LettuceBasedProxyManager<K> proxyManager = Bucket4jLettuce.casBasedBuilder(connection)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)))
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
3.4.3. Redisson integration
Dependencies
To use the bucket4j-redisson extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-redis-common</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-redisson</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation via RedissonBasedProxyManager
// Instantiate Redisson config
Config config = new Config();
// ...
Redisson redissonClient = (Redisson) Redisson.create(config);
RedissonBasedProxyManager<String> proxyManager = Bucket4jRedisson.casBasedBuilder(redissonClient.getCommandExecutor())
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)))
.keyMapper(Mapper.STRING)
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
3.4.4. Jedis integration
Dependencies
To use the bucket4j-jedis extension, add the following dependency:
<!-- For java 17 -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-redis-common</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-jedis</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation via JedisBasedProxyManager
redis.clients.jedis.JedisPool jedisPool = ...;
JedisBasedProxyManager<String> proxyManager = Bucket4jJedis.casBasedBuilder(jedisPool)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)))
.keyMapper(Mapper.STRING)
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
3.4.5. Vert.x Redis client integration
Dependencies
To use the bucket4j-vertx extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-redis-common</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-vertx</artifactId>
<version>8.20.0</version>
</dependency>
Example of bucket instantiation via VertxBasedProxyManager
Redis redis = Redis.createClient(vertx, "redis://127.0.0.1:6379");
VertxBasedProxyManager<String> proxyManager = Bucket4jVertx.casBasedBuilder(redis)
.keyMapper(Mapper.STRING)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)))
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
3.4.6. Valkey Glide integration
Dependencies
To use the bucket4j-glide extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-redis-common</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-glide</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation via GlideBasedProxyManager
BaseClient client = ...;
GlideBasedProxyManager<K> proxyManager = Bucket4jGlide.casBasedBuilder(client)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)))
.build();
...
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy(key, () -> configuration);
3.5. JDBC integrations
General principles for using each JDBC integration:
-
Bucket4j does not create the bucket storage table for you - you need to create it yourself. A DDL example is provided for each integration.
-
Relational databases are not in-memory data grids and have no built-in TTL support, so you should create a trigger or a scheduler to clear expired rows from your bucket storage table.
3.5.1. Overriding the table and column naming scheme
-
tableName- name of the table used as the bucket store. Default value isbucket -
idColumnName- name of the primary key column. Default value isid -
stateColumnName- name of the column used to store the bucket state. Default value isstate
You can override the naming as you wish at proxy-manager build time. Below is an example for MySQL; the code for other integrations is the same.
MySQLSelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jMySQL
.selectForUpdateBasedBuilder(dataSource)
.table("user_buckets")
.idColumn("user_id")
.stateColumn("state_bytes")
.build()
3.5.2. Overriding the type of the primary key
By default, java.lang.Long is used as the Java representation of the primary-key column value,
and the primary key column type is expected to be assignable from Long when binding PreparedStatement parameters.
Sometimes you need the primary key column to use a different type, for example java.lang.String and its corresponding database type.
You can configure a custom primary-key type at proxy-manager build time. Below is an example for PostgreSQL;
the code for other integrations is the same.
CREATE TABLE IF NOT EXISTS bucket(id VARCHAR PRIMARY KEY, state BYTEA);
PostgreSQLSelectForUpdateBasedProxyManager<String> proxyManager = Bucket4jPostgreSQL
.selectForUpdateBasedBuilder(dataSource)
.primaryKeyMapper(PrimaryKeyMapper.STRING)
.build();
There are several predefined mappers defined inside io.github.bucket4j.distributed.jdbc.PrimaryKeyMapper;
if none of them suit your needs, you can define your own by implementing this interface.
3.5.3. Expiration policy
Relational databases have no built-in auto-expiration functionality, unlike, for example, Redis.
For all JDBC integrations, Bucket4j only calculates the expires_at column if an expiration policy is configured.
You then need to manually trigger the removal of expired buckets, as shown below.
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BYTEA, expires_at BIGINT);
PostgreSQLSelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jPostgreSQL
.selectForUpdateBasedBuilder(dataSource)
.expirationAfterWrite(basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(60)))
.build();
private static final int MAX_TO_REMOVE_IN_ONE_TRANSACTION = 1_000;
private static final int THRESHOLD_TO_CONTINUE_REMOVING = 50;
// once per day at 4:30 morning
@Scheduled(cron = "0 30 4 * * *")
public void scheduleFixedDelayTask() {
int removedCount;
do {
removedCount = proxyManager.removeExpired(MAX_TO_REMOVE_IN_ONE_TRANSACTION);
if (removedCount > 0) {
logger.info("Removed {} expired buckets", removedCount);
} else {
logger.info("There are no expired buckets to remove");
}
} while (removedCount > THRESHOLD_TO_CONTINUE_REMOVING);
}
3.5.4. PostgreSQL integration
Dependencies
To use the Bucket4j extension for PostgreSQL, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-postgresql</artifactId>
<version>8.20.0</version>
</dependency>
DDL example
// if the expiration feature is not required
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BYTEA);
// if the expiration feature is required for PostgreSQLSelectForUpdateBasedProxyManager
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BYTEA, expires_at BIGINT);
// if the expiration feature is required for PostgreSQLAdvisoryLockBasedProxyManager
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BYTEA, expires_at BIGINT, explicit_lock BIGINT);
PostgreSQLSelectForUpdateBasedProxyManager
PostgreSQLSelectForUpdateBasedProxyManager is based on the standard SQL SELECT FOR UPDATE syntax.
This prevents the selected rows from being modified or deleted by other transactions until the current transaction ends.
That is, any other transaction that attempts to UPDATE, DELETE, or SELECT FOR UPDATE these rows will be blocked until the current transaction ends.
Also, if an UPDATE, DELETE, or SELECT FOR UPDATE from another transaction has already locked a selected row, SELECT FOR UPDATE will wait for the other transaction to complete, and will then lock and return the updated row (or no row, if the row was deleted).
Within a SERIALIZABLE transaction, however, an error will be thrown if a row to be locked has changed since the transaction started.
PostgreSQLSelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jPostgreSQL
.selectForUpdateBasedBuilder(dataSource)
.build();
...
Long key = 1L;
BucketConfiguration bucketConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.build();
BucketProxy bucket = proxyManager.getProxy(key, () -> bucketConfiguration);
PostgreSQLAdvisoryLockBasedProxyManager
PostgreSQLadvisoryLockBasedProxyManager is based on pg_advisory_xact_lock, which locks an application-defined resource that can be identified either by a single 64-bit key value or two 32-bit key values (note that these two key spaces do not overlap).
If another session already holds a lock on the same resource identifier, this function will wait until the resource becomes available.
The lock is exclusive.
Multiple lock requests stack, so if the same resource is locked three times, it must then be unlocked three times before it becomes available to other sessions.
The lock is automatically released at the end of the current transaction and cannot be released explicitly.
PostgreSQLadvisoryLockBasedProxyManager<Long> proxyManager = Bucket4jPostgreSQL
.advisoryLockBasedBuilder(dataSource)
.build();
...
Long key = 1L;
BucketConfiguration bucketConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.build();
BucketProxy bucket = proxyManager.getProxy(key, () -> bucketConfiguration);
3.5.5. MySQL integration
Dependencies
To use the bucket4j-mysql extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-mysql</artifactId>
<version>8.20.0</version>
</dependency>
DDL example
// if the expiration feature is not required
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BLOB);
// if the expiration feature is required
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BLOB, expires_at BIGINT);
Example of Bucket instantiation
MySQLSelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jMySQL
.selectForUpdateBasedBuilder(dataSource)
.build();
...
Long key = 1L;
BucketConfiguration bucketConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.build();
BucketProxy bucket = proxyManager.getProxy(key, () -> bucketConfiguration);
3.5.6. MariaDB integration
Dependencies
To use the Bucket4j extension for MariaDB, add the following dependency:
<!-- For java 17 -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-mariadb</artifactId>
<version>8.20.0</version>
</dependency>
DDL example
// if the expiration feature is not required
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BLOB);
// if the expiration feature is required
CREATE TABLE IF NOT EXISTS bucket(id BIGINT PRIMARY KEY, state BLOB, expires_at BIGINT);
Example of Bucket instantiation
MariaDBSelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jMariaDB
.selectForUpdateBasedBuilder(dataSource)
.build();
...
Long key = 1L;
BucketConfiguration bucketConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.build();
BucketProxy bucket = proxyManager.getProxy(key, () -> bucketConfiguration);
3.5.7. Oracle database integration
Dependencies
To use the Bucket4j extension for Oracle, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-oracle</artifactId>
<version>8.20.0</version>
</dependency>
DDL example
// if the expiration feature is not required
CREATE TABLE bucket(id NUMBER NOT NULL PRIMARY KEY, state RAW(255));
// if the expiration feature is required
CREATE TABLE bucket(id NUMBER NOT NULL PRIMARY KEY, state RAW(255), expires_at NUMBER);
Example of Bucket instantiation
OracleSelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jOracle
.selectForUpdateBasedBuilder(dataSource)
.build();
...
Long key = 1L;
BucketConfiguration bucketConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.build();
BucketProxy bucket = proxyManager.getProxy(key, () -> bucketConfiguration);
3.5.8. MicrosoftSQLServer integration
Dependencies
To use the Bucket4j extension for Microsoft SQL Server, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-mssql</artifactId>
<version>8.20.0</version>
</dependency>
DDL example
// if the expiration feature is not required
CREATE TABLE bucket(id BIGINT NOT NULL PRIMARY KEY, state BINARY(256))
// if the expiration feature is required
CREATE TABLE bucket(id BIGINT NOT NULL PRIMARY KEY, state BINARY(256), expires_at BIGINT)
Example of Bucket instantiation
MSSQLSelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jMSSQL
.selectForUpdateBasedBuilder(dataSource)
.build();
...
Long key = 1L;
BucketConfiguration bucketConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.build();
BucketProxy bucket = proxyManager.getProxy(key, () -> bucketConfiguration);
3.5.9. IBM Db2 integration
Dependencies
To use the Bucket4j extension for IBM Db2 Server, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-db2</artifactId>
<version>8.20.0</version>
</dependency>
DDL example
// if the expiration feature is not required
CREATE TABLE bucket(id BIGINT NOT NULL PRIMARY KEY, state VARCHAR(512))
// if the expiration feature is required
CREATE TABLE bucket(id BIGINT NOT NULL PRIMARY KEY, state VARCHAR(512), expires_at BIGINT)
Example of Bucket instantiation
Db2SelectForUpdateBasedProxyManager<Long> proxyManager = Bucket4jDb2
.selectForUpdateBasedBuilder(dataSource)
.build();
...
Long key = 1L;
BucketConfiguration bucketConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.build();
BucketProxy bucket = proxyManager.getProxy(key, () -> bucketConfiguration);
3.6. Bucket4j-MongoDB
Bucket4j provides integration with MongoDB database through two modules:
| Module | Async supported | Driver type |
|---|---|---|
|
No |
Synchronous MongoDB Java Driver |
|
Yes |
Reactive Streams MongoDB Java Driver |
|
Important
|
Both modules use Compare&Swap pattern to handle concurrent access to MongoDB. This provides atomic bucket state updates through MongoDB’s atomic operations (insertOne and findOneAndReplace).
|
3.6.1. MongoDB Sync integration
Dependencies
To use the bucket4j-mongodb-sync extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-mongo-common</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-mongodb-sync</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation via MongoDBSyncCompareAndSwapBasedProxyManager
MongoClient mongoClient = MongoClients.create("mongodb://localhost:27017");
MongoDatabase database = mongoClient.getDatabase("bucket4j");
MongoCollection<Document> collection = database.getCollection("buckets");
MongoDBSyncCompareAndSwapBasedProxyManagerBuilder<String> builder =
Bucket4jMongoDBSync.compareAndSwapBasedBuilder(collection)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)));
MongoDBSyncCompareAndSwapBasedProxyManager<String> proxyManager = builder.build();
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucket = proxyManager.getProxy("user-123", () -> configuration);
Custom field names
By default, the MongoDB integration uses the state field to store the bucket state and the expiresAt field for the expiration timestamp. You can customize these field names:
MongoDBSyncCompareAndSwapBasedProxyManager<String> proxyManager =
Bucket4jMongoDBSync.compareAndSwapBasedBuilder(collection)
.stateField("bucketState")
.expiresAtField("ttl")
.build();
Custom key mapping
By default, the integration uses String keys. You can specify custom key types using a Mapper:
MongoDBSyncCompareAndSwapBasedProxyManager<Long> proxyManager =
Bucket4jMongoDBSync.compareAndSwapBasedBuilder(collection, Mapper.LONG)
.build();
Bucket bucket = proxyManager.getProxy(123L, () -> configuration);
3.6.2. MongoDB Async integration
Dependencies
To use the bucket4j-mongodb-async extension, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-mongo-common</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-mongodb-async</artifactId>
<version>8.20.0</version>
</dependency>
Example of Bucket instantiation via MongoDBAsyncCompareAndSwapBasedProxyManager
import com.mongodb.reactivestreams.client.MongoClient;
import com.mongodb.reactivestreams.client.MongoClients;
MongoClient mongoClient = MongoClients.create("mongodb://localhost:27017");
MongoDatabase database = mongoClient.getDatabase("bucket4j");
MongoCollection<Document> collection = database.getCollection("buckets");
MongoDBAsyncCompareAndSwapBasedProxyManagerBuilder<String> builder =
Bucket4jMongoDBAsync.compareAndSwapBasedBuilder(collection)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)));
MongoDBAsyncCompareAndSwapBasedProxyManager<String> proxyManager = builder.build();
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
// Synchronous usage
Bucket bucket = proxyManager.getProxy("user-123", () -> configuration);
// Asynchronous usage
AsyncBucketProxy asyncBucket = proxyManager.asAsync().getProxy("user-456", () -> configuration);
CompletableFuture<ConsumptionProbe> future = asyncBucket.tryConsumeAndReturnRemaining(1);
Custom field names
By default, the MongoDB integration uses the state field to store the bucket state and the expiresAt field for the expiration timestamp. You can customize these field names:
MongoDBAsyncCompareAndSwapBasedProxyManager<String> proxyManager =
Bucket4jMongoDBAsync.compareAndSwapBasedBuilder(collection)
.stateField("bucketState")
.expiresAtField("ttl")
.build();
Custom key mapping
By default, the integration uses String keys. You can specify custom key types using a Mapper:
MongoDBAsyncCompareAndSwapBasedProxyManager<Long> proxyManager =
Bucket4jMongoDBAsync.compareAndSwapBasedBuilder(collection, Mapper.LONG)
.build();
AsyncBucketProxy asyncBucket = proxyManager.asAsync().getProxy(123L, () -> configuration);
Async operations support
The MongoDB async integration fully supports asynchronous operations using CompletableFuture:
AsyncBucketProxy asyncBucket = proxyManager.asAsync().getProxy("user-123", () -> configuration);
// Async consume
CompletableFuture<ConsumptionProbe> consumeFuture = asyncBucket.tryConsumeAndReturnRemaining(10);
consumeFuture.thenAccept(probe -> {
if (probe.isConsumed()) {
System.out.println("Consumed successfully, remaining: " + probe.getRemainingTokens());
} else {
System.out.println("Rate limit exceeded, try again in: " + probe.getNanosToWaitForRefill());
}
});
// Async removal
CompletableFuture<Void> removeFuture = proxyManager.asAsync().removeProxy("user-123");
Expired entries cleanup
The MongoDB async integration supports automatic cleanup of expired entries:
// Remove up to 1000 expired entries
int removedCount = proxyManager.removeExpired(1000);
Important notes
-
This integration supports both synchronous and asynchronous operations -
isAsyncModeSupported()returnstrue -
Uses Reactive Streams MongoDB driver for non-blocking operations
-
Uses MongoDB’s atomic operations for thread-safe bucket state management
-
Bucket state is stored as binary data in the configured state field
3.7. Bucket4j-Couchbase
Bucket4j provides integration with Couchbase through the Couchbase Java SDK KV API, supporting both the synchronous (Collection) and asynchronous (AsyncCollection) facades.
This integration uses compare-and-swap based on Couchbase document CAS. Bucket state is stored as binary document content. Expiration is delegated to Couchbase document TTL.
3.7.1. Couchbase integration
Dependencies
To use bucket4j_jdk17-couchbase, add the following dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-couchbase</artifactId>
<version>8.20.0</version>
</dependency>
<dependency>
<groupId>com.couchbase.client</groupId>
<artifactId>java-client</artifactId>
<version>3.9.1</version>
</dependency>
Example of Bucket instantiation via CouchbaseCompareAndSwapBasedProxyManager
com.couchbase.client.java.Cluster cluster = com.couchbase.client.java.Cluster.connect("127.0.0.1", "Administrator", "password");
com.couchbase.client.java.Bucket couchbaseBucket = cluster.bucket("bucket4j");
couchbaseBucket.waitUntilReady(ofSeconds(10));
com.couchbase.client.java.Collection collection = couchbaseBucket.defaultCollection();
CouchbaseCompareAndSwapBasedProxyManager<String> proxyManager =
Bucket4jCouchbase.compareAndSwapBasedBuilder(collection)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(ofSeconds(10)))
.build();
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1_000).refillGreedy(1_000, ofMinutes(1)))
.build();
Bucket bucketProxy = proxyManager.getProxy("user-123", () -> configuration);
|
Note
|
The example above spells out the com.couchbase.client.java.Cluster/Bucket/Collection types with their full package names because the Couchbase SDK’s own Bucket class would otherwise collide with io.github.bucket4j.Bucket.
|
Asynchronous usage
Bucket4jCouchbase.compareAndSwapBasedBuilder also accepts an AsyncCollection, and any proxy manager built from either overload supports the async API:
CouchbaseCompareAndSwapBasedProxyManager<String> proxyManager =
Bucket4jCouchbase.compareAndSwapBasedBuilder(collection.async())
.build();
AsyncBucketProxy asyncBucket = proxyManager.asAsync().getProxy("user-123", () -> configuration);
CompletableFuture<ConsumptionProbe> future = asyncBucket.tryConsumeAndReturnRemaining(1);
Custom key mapping
By default, the integration uses String keys. You can specify custom key types using a Mapper:
CouchbaseCompareAndSwapBasedProxyManager<Long> proxyManager =
Bucket4jCouchbase.compareAndSwapBasedBuilder(collection, Mapper.LONG)
.build();
Bucket bucket = proxyManager.getProxy(123L, () -> configuration);
Custom key mappers must produce valid Couchbase document ids because Mapper.toString(key) is used as the document key.
4. Distributed facilities advanced topics
4.1. Asynchronous API
Since version 3.0, Bucket4j provides asynchronous equivalents for most of its API methods.
The asynchronous view of a ProxyManager is available through the asAsync() method:
ProxyManager<String> proxyManager = ...;
AsyncProxyManager<String> asyncProxyManager = proxyManager.asAsync();
BucketConfiguration configuration = ...;
AsyncBucketProxy asyncBucket = asyncProxyManager.getProxy(key, () -> CompletableFuture.completedFuture(configuration));
Every method of the AsyncBucketProxy interface has a direct equivalent, with the same semantics, as the corresponding method of the Bucket interface - the only difference is that it returns a CompletableFuture instead of blocking the calling thread.
4.1.1. Example - limiting the rate of access to an asynchronous servlet
Imagine that you are developing an SMS service that allows sending SMS messages via an HTTP interface. You want the architecture to be protected from overloading, clustered, and fully asynchronous.
Overloading protection requirement:
To prevent fraud and service overloading, you want to introduce the following limit for each outbound phone number: the bucket size is 20 SMS messages (which cannot be exceeded at any given time), with a "refill rate" of 10 SMS messages per minute that continuously adds tokens to the bucket. In other words, if a client sends 10 SMS messages per minute, it will never be throttled. Moreover, the client has an overdraft of 20 SMS messages, which can be used if the average is a little higher than 10 SMS/minute over a short period of time. Solution: use Bucket4j for this.
Clustering requirement:
You want to avoid a single point of failure - if one server crashes, information about already-consumed tokens should not be lost. It would therefore be better to store the buckets on a distributed computation platform rather than in the memory of a single server.
Solution: use JBoss Infinispan and the bucket4j-infinispan extension for this. Hazelcast and Apache Ignite would work equally well; Infinispan was picked here purely as an example.
Asynchronous processing requirement: For maximum scalability, you also want the architecture to be fully non-blocking - both sending the SMS and checking the limit should be asynchronous. Solution: use the asynchronous features provided by Bucket4j together with the Servlet API’s asynchronous support.
Mockup of a service built on top of the Servlet API and bucket4j-infinispan:
public class SmsServlet extends javax.servlet.http.HttpServlet {
private SmsSender smsSender;
private AsyncProxyManager<String> buckets;
private Supplier<CompletableFuture<BucketConfiguration>> configurationSupplier;
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
ServletContext ctx = config.getServletContext();
smsSender = (SmsSender) ctx.getAttribute("sms-sender");
FunctionalMapImpl<String, byte[]> bucketMap = (FunctionalMapImpl<String, byte[]>) ctx.getAttribute("bucket-map");
this.buckets = new InfinispanProxyManager(bucketMap).asAsync();
this.configurationSupplier = () -> {
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(20).refillGreedy(10, Duration.ofMinutes(1)))
.build();
return CompletableFuture.completedFuture(configuration);
};
}
@Override
protected void doPost(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException, ServletException {
String fromNumber = httpRequest.getParameter("from");
String toNumber = httpRequest.getParameter("to");
String text = httpRequest.getParameter("text");
AsyncBucketProxy bucket = buckets.getProxy(fromNumber, configurationSupplier);
CompletableFuture<ConsumptionProbe> limitCheckingFuture = bucket.tryConsumeAndReturnRemaining(1);
final AsyncContext asyncContext = httpRequest.startAsync();
limitCheckingFuture.thenCompose(probe -> {
if (!probe.isConsumed()) {
Result throttledResult = Result.throttled(probe);
return CompletableFuture.completedFuture(throttledResult);
} else {
return smsSender.sendAsync(fromNumber, toNumber, text);
}
}).whenComplete((result, exception) -> {
HttpServletResponse asyncResponse = (HttpServletResponse) asyncContext.getResponse();
try {
asyncResponse.setContentType("text/plain");
if (exception != null || result.isFailed()) {
asyncResponse.setStatus(500);
asyncResponse.getWriter().println("Internal Error");
} else if (result.isThrottled()) {
asyncResponse.setStatus(429);
asyncResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + result.getRetryAfter());
asyncResponse.getWriter().append("Too many requests");
} else {
asyncResponse.setStatus(200);
asyncResponse.getWriter().append("Success");
}
} finally {
asyncContext.complete();
}
});
}
}
4.2. Client-side configuration
ClientSideConfig lets you configure client-side behavior for distributed bucket operations, such as request timeouts and retry limits for Compare-And-Swap (CAS) based backends.
Most backends expose this configuration in two equivalent ways:
-
Through dedicated builder methods (
.requestTimeout(…),.maxRetries(…),.retryStrategy(…)) - this is the recommended way, shown below usingBucket4jLettuce.casBasedBuilder(…)as an example. -
Through an explicit
ClientSideConfigobject passed to.withClientSideConfig(config)- useful when you want to build the configuration once and share or reuse it across several proxy managers.
4.2.1. Request timeout
You can configure a timeout for distributed bucket operations:
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient)
.requestTimeout(Duration.ofSeconds(3))
.build();
The way a configured timeout is enforced depends on the backend: some backends use native per-request timeouts (for example, JDBC statement timeouts), others enforce the timeout at the Bucket4j library level (for example, by timing out the returned CompletableFuture), and a few backends may not be able to honor it at all. Where the timeout is enforced, exceeding it throws a TimeoutException.
4.2.2. Max retries for CAS operations
For CAS-based backends (Redis, Hazelcast, Ignite, etc.), you can limit the number of retry attempts to prevent long-running or effectively infinite loops under high contention.
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient)
.maxRetries(10) // limit to 10 CAS retry attempts
.build();
When the number of retries is exceeded, a BucketExecutionException is thrown.
Default behavior: if maxRetries is not configured, CAS operations retry indefinitely (unless a request timeout is also configured).
4.2.3. Combining timeout and max retries
You can combine both settings for comprehensive protection - whichever limit is reached first wins:
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient)
.maxRetries(5) // max 5 retry attempts
.requestTimeout(Duration.ofSeconds(2)) // max 2 seconds total
.build();
-
BucketExecutionExceptionis thrown if 5 retries are exceeded first. -
TimeoutExceptionis thrown if the 2-second timeout is exceeded first.
4.2.4. Custom retry strategy for CAS operations
For advanced use cases, you can provide a custom RetryStrategy that makes dynamic decisions based on metadata about the current retry attempt. Unlike a plain retry counter, RetryStrategy can also introduce a delay before the next attempt:
RetryStrategy customStrategy = metadata -> {
int attemptNumber = metadata.getAttemptNumber();
String bucketKey = (String) metadata.getBucketKey();
logger.info("CAS retry attempt {} for bucket {}", attemptNumber, bucketKey);
return attemptNumber < 5 ? RetryDecision.retryImmediately() : RetryDecision.stop();
};
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient)
.retryStrategy(customStrategy)
.build();
RetryStrategy is a functional interface: RetryDecision shouldRetry(RetryMetadata metadata). It is invoked after every failed CAS attempt, and the returned RetryDecision tells Bucket4j how to proceed:
-
RetryDecision.stop()- give up and report failure. -
RetryDecision.retryImmediately()- retry right away. -
RetryDecision.retryAfter(Duration)- wait for the given delay before retrying (useful for backoff).
RetryMetadata provides:
-
getAttemptNumber()- the current attempt number (1-based). -
getBucketKey()- the bucket identifier/key. -
getStartTimeNanos()/getCurrentTimeNanos()/getElapsedTimeNanos()- timing information about the retry loop, useful for time-based backoff or giving up after a deadline.
Alternatively, the same strategy can be supplied through ClientSideConfig, for example when you want to share one configuration object across several proxy managers:
ClientSideConfig config = ClientSideConfig.getDefault()
.withMaxRetries(5)
.withRequestTimeout(Duration.ofSeconds(2))
.withRetryStrategy(customStrategy);
ProxyManager<String> proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient)
.withClientSideConfig(config)
.build();
Note: if both retryStrategy() and maxRetries() are configured, the RetryStrategy takes precedence.
|
Important
|
Under high contention on a hot bucket key, immediate retries tend to re-collide: all losers of a CAS race retry at the same moment, so the conflict repeats. Prefer retryAfter with randomized (jittered) delays to desynchronize competing clients, and prefer a time-budget stop condition over an attempt-count stop — an attempt count can be exhausted in a few milliseconds and reject a request even though the bucket still has tokens available.
|
Common use cases:
Simple retry limit:
RetryStrategy limitedRetries = metadata ->
metadata.getAttemptNumber() < 5 ? RetryDecision.retryImmediately() : RetryDecision.stop();
Exponential backoff with jitter and time budget (recommended under contention):
RetryStrategies ships a ready-made implementation of this pattern, so you do not
need to hand-roll it:
RetryStrategy backoffWithJitter = RetryStrategies.exponentialBackoffWithJitter(
Duration.ofMicros(500), // base delay - at least one round trip to the backend
Duration.ofMillis(20), // max delay - caps added latency under sustained contention
Duration.ofMillis(150)); // budget - total time before giving up, not attempt count
ClientSideConfig config = ClientSideConfig.getDefault()
.withRetryStrategy(backoffWithJitter);
The delay before each attempt is chosen uniformly at random between half and the full value of an exponentially growing (and capped) base delay, so that clients which failed on the same attempt number do not all retry at the same instant. The stop condition is elapsed wall-clock time rather than attempt count, which avoids rejecting a request whose attempts merely happened to run fast.
Tuning guidance:
-
Base delay — the CAS executor re-reads backend state before each retry attempt, so the delay’s purpose is to desynchronize competing clients rather than to wait for fresh data. 0.5-1ms is typical for Redis.
-
Max delay — 20-50ms bounds each individual backoff sleep and spreads out retries under sustained contention. The cumulative retry latency is bounded by
budget. -
Budget — maximum wall-clock time for scheduling retry delays. Once the jittered delay no longer fits within the remaining budget, the strategy stops. The CAS executor may run one final attempt after waking, so total elapsed time may slightly exceed the budget by one backend round-trip. Set as a fraction of the endpoint’s overall latency SLA, since any request that reaches the budget has already waited that long.
-
Keep
withRequestTimeoutconfigured as a second backstop alongside the budget — whichever limit is reached first applies.
Limits, stated honestly: backoff and jitter reduce contention and false rejections, but they cannot eliminate the underlying two-round-trip read-modify-CAS race — each round still admits roughly one winner per hot key. If a single key must eventually absorb far higher concurrency than backoff can serve fairly, the only way to remove the race entirely is to move the bucket arithmetic server-side (for example, into a Lua script executed atomically by Redis), which is a larger change outside the scope of this configuration option.
Bucket-specific retry logic:
RetryStrategy bucketSpecific = metadata -> {
String bucketKey = (String) metadata.getBucketKey();
return shouldKeepRetrying(bucketKey, metadata.getAttemptNumber())
? RetryDecision.retryImmediately()
: RetryDecision.stop();
};
Integration with metrics:
RetryStrategy withMetrics = metadata -> {
metricsCollector.recordRetry(metadata.getBucketKey(), metadata.getAttemptNumber());
return metadata.getAttemptNumber() < 5 ? RetryDecision.retryImmediately() : RetryDecision.stop();
};
Delegating to another component:
RetryStrategy mediatorBased = metadata ->
retryMediator.decide(metadata.getBucketKey(), metadata.getAttemptNumber());
4.3. Implicit configuration replacement
A distributed bucket operates with the configuration that was provided at the time it was created. Passing a new configuration through getProxy/builder() has no effect if the bucket already exists in persistent storage, because the configuration is stored together with the bucket’s state. The only way to change the configuration of an already-persisted bucket is to explicitly call replaceConfiguration (or its async equivalent).
-
It requires the library client to write dedicated code for configuration replacement. This is unnecessary busywork, and it is especially painful when Bucket4j is used behind a high-level framework like
bucket4j-spring-boot-starter, where end users are not expected to work directly with Bucket4j’s low-level API. -
It can confuse users in the following scenario: a user stores limits in a properties or YAML file, updates that file, and restarts the application - only to be surprised that the new limits are not applied to buckets that survived the restart in storage, because (as mentioned above)
replaceConfigurationmust be called explicitly for each already-persisted bucket. -
For some storage technologies, such as Redis, it is costly to enumerate all buckets persisted in storage, because there is no grouping mechanism equivalent to a table or cache - identifying them requires scanning every key, including keys unrelated to rate limiting.
The implicit configuration replacement feature solves the problems described above. It works based on a configuration version: when a bucket detects that its persisted configuration version is lower than the version provided through the builder API, the persisted configuration is automatically replaced, without any extra action from the client. Both RemoteBucketBuilder and RemoteAsyncBucketBuilder provide the API to configure the desired configuration version.
BucketConfiguration config = ...;
BucketProxy bucket = proxyManager.builder()
.withImplicitConfigurationReplacement(1, TokensInheritanceStrategy.PROPORTIONALLY)
.build(666L, () -> config);
4.4. Framework for implementing support for a custom database
Bucket4j ships with ProxyManager implementations for a number of backends (Redis, Hazelcast, Apache Ignite, Infinispan, Oracle Coherence, PostgreSQL, MySQL, MariaDB, MSSQL, DB2, Oracle, MongoDB, Couchbase), but you are not limited to them.
If your storage of choice is not on this list, you can implement a ProxyManager for it using the generic framework described below, as long as your storage supports one of the following:
-
Pessimistic locking - an exclusive lock can be acquired on a row/entry by key, and released later in the same transaction.
-
SELECT … FOR UPDATE-style semantics - a row/entry can be locked and its current value read in a single atomic operation, similar to how relational databases lock rows for update.
4.4.1. Step 1 - choose and extend the right base class
Extend one of the following classes, depending on which locking style your storage supports:
-
io.github.bucket4j.distributed.proxy.generic.pessimistic_locking.AbstractLockBasedProxyManager<K>- for storages that provide explicit, exclusive locks. -
io.github.bucket4j.distributed.proxy.generic.select_for_update.AbstractSelectForUpdateBasedProxyManager<K>- for storages that provideSELECT … FOR UPDATE-style semantics.
Both classes take care of the whole command-execution protocol (locking, reading, applying the command, persisting, unlocking, committing) - your subclass only needs to teach them how to talk to your specific storage. This comes down to implementing three methods:
-
allocateTransaction(K key, Optional<Long> requestTimeoutNanos)- aprotected abstractmethod that returns a fresh transaction object (LockBasedTransactionorSelectForUpdateBasedTransaction, described below) bound to the given key. -
removeProxy(K key)- inherited from theProxyManagerinterface; deletes the persisted bucket state for the given key from your storage. -
getProxyConfiguration(K key)- inherited from theProxyManagerinterface; reads back theBucketConfigurationthat was persisted for the given key, or an emptyOptionalif no bucket exists for that key yet.
4.4.2. Step 2 - implement the transaction interface
The transaction object you return from allocateTransaction is where all the storage-specific work happens - beginning/committing/rolling back a transaction, locking a row, and reading or writing the bucket’s binary state.
LockBasedTransaction (package io.github.bucket4j.distributed.proxy.generic.pessimistic_locking), used together with AbstractLockBasedProxyManager:
public interface LockBasedTransaction {
// Begins a transaction, if the underlying storage requires one.
// commit(...) or rollback() is guaranteed to be called if begin(...) returns successfully.
void begin(Optional<Long> timeoutNanos);
// Rolls back the transaction, if the underlying storage requires one.
void rollback();
// Commits the transaction, if the underlying storage requires one.
void commit(Optional<Long> timeoutNanos);
// Locks the row/entry associated with the key and returns its current data.
// unlock() is guaranteed to be called if lockAndGet(...) returns successfully.
// Returns null if no data is associated with the key yet.
byte[] lockAndGet(Optional<Long> timeoutNanos);
// Unlocks the row/entry associated with the key.
void unlock();
// Persists the initial state of a bucket that did not exist before.
void create(byte[] data, RemoteBucketState state, Optional<Long> timeoutNanos);
// Persists the updated state of a bucket that already existed.
void update(byte[] data, RemoteBucketState newState, Optional<Long> timeoutNanos);
// Frees any resources held by this transaction (connections, statements, etc).
void release();
}
As a reference implementation, see HazelcastLockBasedProxyManager or PostgreSQLadvisoryLockBasedProxyManager.
SelectForUpdateBasedTransaction (package io.github.bucket4j.distributed.proxy.generic.select_for_update), used together with AbstractSelectForUpdateBasedProxyManager:
public interface SelectForUpdateBasedTransaction {
// Begins a transaction, if the underlying storage requires one.
// commit(...) or rollback() is guaranteed to be called if begin(...) returns successfully.
void begin(Optional<Long> timeoutNanos);
// Rolls back the transaction, if the underlying storage requires one.
void rollback();
// Commits the transaction, if the underlying storage requires one.
void commit(Optional<Long> timeoutNanos);
// Atomically locks and reads the row/entry associated with the key.
// Returns LockAndGetResult.notLocked() if no row exists yet for the key (see tryInsertEmptyData below).
LockAndGetResult tryLockAndGet(Optional<Long> timeoutNanos);
// Inserts an empty row for the key, so that it can be locked by tryLockAndGet(...) in a subsequent transaction.
// Returns true if a row was actually inserted (it may already have been inserted by a concurrent request).
boolean tryInsertEmptyData(Optional<Long> timeoutNanos);
// Persists the updated state of the bucket.
void update(byte[] data, RemoteBucketState newState, Optional<Long> timeoutNanos);
// Frees any resources held by this transaction (connections, statements, etc).
void release();
}
As a reference implementation, see PostgreSQLSelectForUpdateBasedProxyManager, MySQLSelectForUpdateBasedProxyManager, or any of the other SQL-based proxy managers - they all follow the same pattern and differ mainly in the SQL dialect used to lock rows.
4.4.3. Step 3 - implement removeProxy and getProxyConfiguration
These two methods come from the ProxyManager interface itself, rather than from AbstractLockBasedProxyManager/AbstractSelectForUpdateBasedProxyManager, so they are not covered by the transaction abstraction above - implement them directly against your storage:
-
removeProxy(K key)- delete the row/entry associated with the key. -
getProxyConfiguration(K key)- read the row/entry associated with the key and, if present, deserialize and return itsBucketConfiguration.
Once all three pieces are in place, your class can be used through the regular ProxyManager API exactly like any of the built-in backends.