NAME

Acme::Parataxis::RateLimiter - Token-Bucket Rate Limiter for Fibers

SYNOPSIS

use v5.40;
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::RateLimiter;

async {
    my $limiter = Acme::Parataxis::RateLimiter->new( rate => 5, burst => 10 );

    my @workers = map {
        my $url = $_;
        fiber {
            $limiter->acquire;    # parks when tokens are exhausted
            fetch_url($url);
        };
    } @urls;

    $_->await for @workers;

    $limiter->stop;               # required, or the run will never finish
};

DESCRIPTION

Acme::Parataxis::RateLimiter provides an application-wide speed limit for operations interacting with external, rate-limited services. Built on the classic token-bucket algorithm, the limiter starts holding burst tokens. Each request consumes tokens (one by default), while a background loop continuously refills the bucket at rate tokens per second up to the configured ceiling. When the bucket is depleted, calling acquire parks the calling fiber until the next token is credited.

The bucket itself is an Acme::Parataxis::Semaphore, meaning acquire is a standard down operation. This architecture guarantees zero polling overhead: parked fibers suspend cleanly in the scheduler, inherit with_timeout and cancellation token support, and automatically unregister if interrupted.

Refills are driven by an internal Acme::Parataxis::Ticker waking rate times per second. Each tick calculates the wall-clock duration elapsed since the last wake and credits all accrued whole tokens (carrying fractional remainders over). Tokens only accumulate while the bucket is below burst; an idle limiter stops topping up once full and will never burst beyond its ceiling.

Crucial Lifecycle Rule: Because a running limiter maintains background refill fibers, an unstopped limiter keeps the scheduler run open indefinitely. Always call stop() when the limiter is no longer needed.

BURST VS. SUSTAINED RATE

The burst parameter governs how much traffic can be dispatched instantaneously:

  • The bucket starts full. With burst => 10, the first 10 requests pass through instantly without waiting. Only the 11th request will park for a refill.

  • burst addresses front-end latency, not sustained throughput. Over any time window T (in seconds), the maximum number of permits granted is strictly bounded by:

    rate * T + burst
  • Setting burst => 1 produces strict, uniform pacing where requests are spaced evenly by 1000 / rate milliseconds. Setting burst higher allows bursts to absorb concurrency spikes (e.g. initial fan-outs) up to the limit permitted by the upstream service.

METHODS

new( rate => $per_second, burst => $int )

my $rl = Acme::Parataxis::RateLimiter->new( rate => 10, burst => 20 );

Constructs a new rate limiter. Both arguments are keyword-only and required:

  • rate: Permits generated per second. Must be positive. Fractional rates are supported (e.g. 0.5 generates one token every two seconds).

  • burst: Maximum bucket capacity. Must be an integer >= 1.

acquire( [$n = 1] )

$rl->acquire;        # acquire 1 token
$rl->acquire(5);     # acquire 5 tokens

Consumes $n tokens from the bucket, parking the calling fiber if the bucket cannot currently fulfill the request. Tokens are reserved sequentially: an acquire(5) against a bucket with 2 available tokens consumes those 2 immediately and parks until the remaining 3 are refilled.

Returns true on completion. Croaks if called outside a scheduled fiber, if $n is not a positive integer, or if the limiter has already been stopped.

stop( )

$rl->stop;

Halts background refills and stops the internal ticker. Any fibers currently parked in acquire are immediately released through to prevent deadlocking an exiting run. Subsequent calls to acquire croak immediately. Idempotent.

tokens( )

Returns the number of tokens currently available to spend (between 0 and burst).

waiters( )

Returns the number of fibers currently parked waiting for tokens.

running( )

Returns true if the limiter is active and refilling; false if stop() has been called.

rate( ) / burst( )

Read-only accessors returning the initial configuration values.

Recipes

Scoped lifetime with defer

Ensure the limiter stops when the enclosing block exits, even if an exception is thrown:

use v5.40;
use experimental 'defer';
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::RateLimiter;

async {
    my $limiter = Acme::Parataxis::RateLimiter->new( rate => 20, burst => 5 );
    defer { $limiter->stop };

    # Worker tasks can safely acquire permits...
};

Bounded batch reservations

To ensure an entire batch of requests dispatches simultaneously without trickling:

async {
    my $rl = Acme::Parataxis::RateLimiter->new( rate => 10, burst => 5 );
    defer { $rl->stop };

    fiber {
        # Reserves 15 tokens; parks until the entire batch quota is accrued:
        $rl->acquire(15);
        dispatch_batch(@items);
    };
};

Deterministic mock testing

Rate limiter refills consult mock_time, allowing timing-sensitive logic to be verified instantly under virtual clock runs:

use Acme::Parataxis;

Acme::Parataxis->run(
    virtual => 1,
    code    => sub {
        my $rl = Acme::Parataxis::RateLimiter->new( rate => 10, burst => 1 );
        $rl->acquire;    # spend initial burst token; bucket now empty

        my $unblocked = 0;
        fiber {
            $rl->acquire;
            $unblocked = 1;
        };
        Acme::Parataxis->yield;

        # Step the virtual clock by 100ms (1 token refill):
        Acme::Parataxis->advance(100);
        Acme::Parataxis->yield;

        say "Unblocked: $unblocked";    # 1
        $rl->stop;
    }
);

Behavior Notes

  • Unstopped limiters prevent run completion

    The background refill fiber remains parked waiting on ticker boundaries. If stop() is omitted, run() or async will never observe an idle state and will hang waiting for all fibers to exit.

  • Stop within the scheduler run

    stop() coordinates with the scheduler. Do not attempt to call stop() on an active limiter from outside a running fiber session.

  • Non-atomic multi-token acquisitions

    Because acquire($n) consumes tokens as they become available, an acquisition cut short by with_timeout will have already spent any partial tokens accumulated prior to the timeout. The bucket recovers naturally via scheduled refills.

SEE ALSO

Acme::Parataxis, Acme::Parataxis::Semaphore, Acme::Parataxis::Ticker

AUTHOR

Sanko Robinson https://github.com/sanko

LICENSE

Copyright (C) Sanko Robinson.

This library is free software; you can redistribute it and/or modify it under the terms found in the Artistic License 2.