{"id":13878012,"url":"https://github.com/cheddar-me/pecorino","last_synced_at":"2025-07-16T14:30:38.326Z","repository":{"id":205119628,"uuid":"712096860","full_name":"cheddar-me/pecorino","owner":"cheddar-me","description":"Rate limiter for Rails based on leaky buckets","archived":false,"fork":false,"pushed_at":"2024-05-20T09:41:54.000Z","size":184,"stargazers_count":62,"open_issues_count":4,"forks_count":2,"subscribers_count":7,"default_branch":"main","last_synced_at":"2024-10-29T00:29:27.834Z","etag":null,"topics":["activerecord","rack","rate-limiting"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/cheddar-me.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2023-10-30T19:32:14.000Z","updated_at":"2024-10-25T15:53:48.000Z","dependencies_parsed_at":"2024-03-20T12:49:30.480Z","dependency_job_id":null,"html_url":"https://github.com/cheddar-me/pecorino","commit_stats":null,"previous_names":["cheddar-me/pecorino"],"tags_count":8,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cheddar-me%2Fpecorino","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cheddar-me%2Fpecorino/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cheddar-me%2Fpecorino/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cheddar-me%2Fpecorino/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cheddar-me","download_url":"https://codeload.github.com/cheddar-me/pecorino/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":226134226,"owners_count":17578778,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activerecord","rack","rate-limiting"],"created_at":"2024-08-06T08:01:37.504Z","updated_at":"2025-07-16T14:30:37.233Z","avatar_url":"https://github.com/cheddar-me.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"# Pecorino\n\nPecorino is a rate limiter based on the concept of leaky buckets, or more specifically - based on the [generic cell rate](https://brandur.org/rate-limiting) algorithm. It uses your DB as the storage backend for the throttles. It is compact, easy to install, and does not require additional infrastructure. The approach used by Pecorino has been previously used by [prorate](https://github.com/WeTransfer/prorate) with Redis, and that approach has proven itself.\n\nPecorino is designed to integrate seamlessly into any Rails application, and will use either:\n\n* A memory store (good enough if you have just 1 process)\n* A PostgreSQL or SQLite database (at the moment there is no MySQL support, we would be delighted if you could add it)\n* A Redis instance\n\nIf you would like to know more about the leaky bucket algorithm: [this article](http://live.julik.nl/2022/08/the-unreasonable-effectiveness-of-leaky-buckets) or the [Wikipedia article](https://en.wikipedia.org/wiki/Leaky_bucket) are both good starting points. [This Wikipedia article](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm) describes the generic cell rate algorithm in more detail as well.\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'pecorino'\n```\n\nAnd then execute:\n\n    $ bundle install\n    $ bin/rails g pecorino:install\n    $ bin/rails db:migrate\n\n## Usage\n\nOnce the installation is done you can use Pecorino to start defining your throttles. Imagine you have a resource called `vault` and you want to limit the number of updates to it to 5 per second. To achieve that, instantiate a new `Throttle` in your controller or job code, and then trigger it using `Throttle#request!`. A call to `request!` registers 1 token getting added to the bucket. If the bucket would overspill (your request would make it overflow), or the throttle is currently in \"block\" mode (has recently been triggered), a `Pecorino::Throttle::Throttled` exception will be raised.\n\nWe call this pattern **prefix usage** - apply throttle before allowing the action to proceed. This is more secure than registering an action after it has taken place.\n\n```ruby\nthrottle = Pecorino::Throttle.new(key: \"password-attempts-#{the_request.ip}\", over_time: 1.minute, capacity: 5, block_for: 30.minutes)\nthrottle.request!\n```\nIn a Rails controller you can then rescue from this exception to render the appropriate response:\n\n```ruby\nrescue_from Pecorino::Throttle::Throttled do |e|\n  response.set_header('Retry-After', e.retry_after.to_s)\n  render nothing: true, status: 429\nend\n```\n\nand in a Rack application you can rescue inline:\n\n```ruby\ndef call(env)\n  # ...your code\nrescue Pecorino::Throttle::Throttled =\u003e e\n  [429, {\"Retry-After\" =\u003e e.retry_after.to_s}, []]\nend\n```\n\nThe exception has an attribute called `retry_after` which you can use to render the appropriate 429 response.\n\nAlthough this approach might be susceptible to race conditions, you can interrogate your throttle before potentially causing an exception - and display an appropriate error message if the throttle would trigger anyway:\n\n```ruby\nreturn render :capacity_exceeded unless throttle.able_to_accept?\n```\n\nIf you are dealing with a metered resource (like throughput, money, amount of storage...) you can supply the number of tokens to either `request!` or `able_to_accept?` to indicate the desired top-up of the leaky bucket. For example, if you are maintaining user wallets and want to ensure no more than 100 dollars may be taken from the wallet within a certain amount of time, you can do it like so:\n\n```ruby\nthrottle = Pecorino::Throttle.new(key: \"wallet_t_#{current_user.id}\", over_time_: 1.hour, capacity: 100, block_for: 3.hours)\nthrottle.request!(20) # Attempt to withdraw 20 dollars\nthrottle.request!(20) # Attempt to withdraw 20 dollars more\nthrottle.request!(20) # Attempt to withdraw 20 dollars more\nthrottle.request!(20) # Attempt to withdraw 20 dollars more\nthrottle.request!(20) # Attempt to withdraw 20 dollars more\nthrottle.request!(2) # Attempt to withdraw 2 dollars more, will raise `Throttled` and block withdrawals for 3 hours\n```\n\n## Performing a block only if it would be allowed by the throttle\n\nYou can use Pecorino to avoid nuisance alerting - use it to limit the alert rate:\n\n```ruby\nalert_nuisance_t = Pecorino::Throttle.new(key: \"disk-full-alert\", over_time_: 2.hours, capacity: 1, block_for: 2.hours)\nalert_nuisance_t.throttled do\n  Slack.alerts.deliver(\"Disk is full again! please investigate!\")\nend\n```\n\nThis will not raise any exceptions. The `throttled` method performs **prefix throttling** to prevent multiple callers hitting the throttle at the same time, so it is guaranteed to be atomic.\n\n## Postfix topup of the throttle\n\nIn addition to use case where you would want to trigger the throttle before performing an action, there are legitimate use cases where you actually want to use the throttle as a _meter_ instead, measuring the effect of an action which has already been permitted – and then only make it trigger on a subsequent action. This **postfix usage** is less secure, but it allows for a different sequencing of calls. Imagine you want to implement the popular [circuit breaker pattern](https://dzone.com/articles/introduction-to-the-circuit-breaker-pattern) where all your nodes are able to share the error rate information between them. Pecorino gives you all the tools to implement a binary state circuit breaker (open or closed) based on an error rate. Imagine you want to stop sending requests if the service you are calling raises `Timeout::Error` frequently. Then your call to the service could look like this:\n\n```ruby\nbegin\n  error_rate_throttle = Pecorino::Throttle.new(\"some-fancy-ai-api-errors\", capacity: 10, over_time: 30.seconds, block_for: 120.seconds)\n\n  if error_rate_throttle.able_to_accept? # See whether adding 1 request will overflow the error rate\n    fancy_ai_api.post_chat_message(\"Imagine I am a rocket scientist on a moonbase. Invent me...\")\n  else\n    raise \"The error rate for fancy_ai_api has been exceeded\"\n  end\nrescue Timeout::Error\n  error_rate_throttle.request(1) # use bang-less method since we do not need the Throttled exception\n  raise\nend\n```\n\nThis way, every time there is an error on the \"fancy AI service\" the throttle will be triggered, and if it overflows - a subsequent request will be blocked.\n\n## A note on database transactions\n\nPecorino uses your main database. When calling the `Throttle` or `LeakyBucket` objects, SQL queries will be performed by Pecorino and those queries may result in changes to data. If you are currently inside a database transaction, your bucket topups or set blocks may get reverted. For example, imagine you have a controller like this:\n\n```ruby\nclass WalletController \u003c ApplicationController\n  rescue_from Pecorino::Throttle::Throttled do |e|\n    response.set_header('Retry-After', e.retry_after.to_s)\n    render nothing: true, status: 429\n  end\n\n  def withdraw\n    Wallet.transaction do\n      t = Pecorino::Throttle.new(\"wallet_#{current_user.id}_max_withdrawal\", capacity: 200_00, over_time: 5.minutes)\n      t.request!(10_00)\n      current_user.wallet.withdraw(Money.new(10, \"EUR\"))\n    end\n  end\nend\n```\n\nwhat will happen is that even though the `withdraw()` call is not going to be performed, the increment of the throttle will not either, because the exception will result in a `ROLLBACK`.\n\nIf you need to use Pecorino in combination with transactions, you will need to design with that in mind. Either call `Throttle` before entering the `transaction do`:\n\n```ruby\ndef withdraw\n  t = Pecorino::Throttle.new(\"wallet_#{current_user.id}_max_withdrawal\", capacity: 200_00, over_time: 5.minutes)\n  t.request!(10_00)\n  Wallet.transaction do\n    current_user.wallet.withdraw(Money.new(10, \"EUR\"))\n  end\nend\n```\n\nor use the `request()` method instead to still commit:\n\n```ruby\ndef withdraw\n  Wallet.transaction do\n    t = Pecorino::Throttle.new(\"wallet_#{current_user.id}_max_withdrawal\", capacity: 200_00, over_time: 5.minutes)\n    throttle_state = t.request(10_00)\n    return render(nothing: true, status: 429) if throttle_state.blocked?\n\n    current_user.wallet.withdraw(Money.new(10, \"EUR\"))\n  end\nend\n```\n\nNote also that this behaviour might be desirable for your use case (that the throttle and the data update together in\na transactional manner) – it just helps to be aware of it.\n\n## Using just the leaky bucket\n\nSometimes you don't want to use a throttle, but you want to track the amount added to the leaky bucket over time. A lower-level abstraction is available for that purpose in the form of the `LeakyBucket` class. It will not raise any exceptions and will not install blocks, but will permit you to track a bucket's state over time:\n\n\n```ruby\nb = Pecorino::LeakyBucket.new(key: \"some_b\", capacity: 100, leak_rate: 1)\nb.fillup(2) #=\u003e Pecorino::LeakyBucket::State(full?: false, level: 2.0)\nsleep 0.2\nb.state #=\u003e Pecorino::LeakyBucket::State(full?: false, level: 1.8)\n```\n\nCheck out the inline YARD documentation for more options. Do take note of the differences between `fillup()` and `fillup_conditionally` as you\nmight want to pick one or the other depending on your use case.\n\n## Cleaning out stale buckets and blocks from the database\n\nWe recommend running the following bit of code every couple of hours (via cron or similar) to delete the stale blocks and leaky buckets from the system:\n\n```ruby\nPecorino.prune!\n```\n\n## Testing your application\n\nThe Pecorino buckets and blocks are stateful. If you are not running tests with a transaction rollback, the rate limiters that got hit in a test case may interfere with other test cases you are running. Normally you will not notice this (if you are using the same database as the rest of your models), but we recommend adding this section to your global test case setup:\n\n```ruby\nsetup do\n  # Delete all transient records\n  ActiveRecord::Base.connection.execute(\"TRUNCATE TABLE pecorino_blocks\")\n  ActiveRecord::Base.connection.execute(\"TRUNCATE TABLE pecorino_leaky_buckets\")\nend\n```\n\nIf you are using Redis, you may want to ensure it gets truncated/reset for every test case - or that parallel test case runners [each use a separate Redis database.](https://redis.io/docs/latest/commands/select/)\n\n## Using cached throttles\n\nIf a throttle is triggered, Pecorino sets a \"block\" record for that throttle key. Any request to that throttle will fail until the block is lifted. If you are getting hammered by requests which are getting throttled, it might be a good idea to install a caching layer which will respond with a \"rate limit exceeded\" error even before hitting your database - until the moment when the block would be lifted. You can use any [ActiveSupport::Cache::Store](https://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html) to store your blocks. If you have a fast Rails cache configured, create a wrapped throttle:\n\n```ruby\nthrottle = Pecorino::Throttle.new(key: \"ip-#{the_request.ip}\", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)\ncached_throttle = Pecorino::CachedThrottle.new(Rails.cache, throttle)\ncached_throttle.request!\n```\n\nNote that the idea of using a cache store here is to avoid hitting the database when the block for your throttle is in effect. Therefore, if you are using something like [solid_cache](https://github.com/rails/solid_cache) you will be hitting the database regardless! A better approach is to have a [MemoryStore](https://api.rubyonrails.org/classes/ActiveSupport/Cache/MemoryStore.html) just for throttles - it will be local to your Rails process. This will avoid a database roundtrip once the process knows a particular throttle is being blocked at the moment:\n\n```ruby\n# in application.rb\nconfig.pecorino_throttle_cache = ActiveSupport::Cache::MemoryStore.new\n\n# in your controller\n\nthrottle = Pecorino::Throttle.new(key: \"ip-#{the_request.ip}\", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)\ncached_throttle = Pecorino::CachedThrottle.new(Rails.application.config.pecorino_throttle_cache, throttle)\ncached_throttle.request!\n```\n\n## Using unlogged tables for reduced replication load (PostgreSQL)\n\nThrottles and leaky buckets are transient resources. If you are using Postgres replication, it might be prudent to set the Pecorino tables to `UNLOGGED` which will exclude them from replication - and save you bandwidth and storage on your RR. To do so, add the following statements to your migration:\n\n```ruby\nActiveRecord::Base.connection.execute(\"ALTER TABLE pecorino_leaky_buckets SET UNLOGGED\")\nActiveRecord::Base.connection.execute(\"ALTER TABLE pecorino_blocks SET UNLOGGED\")\n```\n\n## Development\n\nAfter checking out the repo, run `bundle install` and then do the thing you need to do.\n\n**Note:** CI runs other Gemfiles, because we can't test all Ruby versions and Rails versions just by swapping Gemfiles. If you need to debug something with a particular Ruby and Rails version, do this:\n\n```bash\n$ bundle rbenv local 2.7.7 \u0026\u0026 export BUNDLE_GEMFILE=gemfiles/Gemfile_ruby27_rails7 \u0026\u0026 bundle install\n$ bundle exec rake\n```\n\nThen proceed as normal. Make sure to unset `BUNDLE_GEMFILE` when you are done. CI will run both the oldest supported dependencies and newest supported dependencies. \n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/cheddar-me/pecorino. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/cheddar-me/pecorino/blob/main/CODE_OF_CONDUCT.md).\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n\n## Code of Conduct\n\nEveryone interacting in the Pecorino project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/cheddar-me/pecorino/blob/main/CODE_OF_CONDUCT.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcheddar-me%2Fpecorino","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcheddar-me%2Fpecorino","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcheddar-me%2Fpecorino/lists"}