GithubHelp home page GithubHelp logo

exhammer / hammer Goto Github PK

View Code? Open in Web Editor NEW
702.0 5.0 40.0 214 KB

An Elixir rate-limiter with pluggable backends

Home Page: https://hexdocs.pm/hammer/

License: MIT License

Elixir 100.00%
elixir elixir-lang rate-limiting rate-limiter phoenix-framework phoenix

hammer's Introduction

hammer

Hammer

Build Status Hex.pm Documentation Total Download License

A rate-limiter for Elixir, with pluggable storage backends.

Hammer-Plug

We have a helper-library to make adding rate-limiting to your Phoenix (or other plug-based) application even easier: Hammer.Plug.

Installation

Hammer is available in Hex, the package can be installed by adding :hammer to your list of dependencies in mix.exs:

def deps do
  [
    {:hammer, "~> 6.1"}
  ]
end

Documentation

On HexDocs: https://hexdocs.pm/hammer/frontpage.html

The Tutorial is an especially good place to start.

Usage

Example:

defmodule MyApp.VideoUpload do

  def upload(video_data, user_id) do
    case Hammer.check_rate("upload_video:#{user_id}", 60_000, 5) do
      {:allow, _count} ->
        # upload the video, somehow
      {:deny, _limit} ->
        # deny the request
    end
  end

end

The Hammer module provides the following functions:

  • check_rate(id, scale_ms, limit)
  • check_rate_inc(id, scale_ms, limit, increment)
  • inspect_bucket(id, scale_ms, limit)
  • delete_buckets(id)

Backends are configured via Mix.Config:

config :hammer,
  backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4,
                                 cleanup_interval_ms: 60_000 * 10]}

See the Tutorial for more.

See the Hammer Testbed app for an example of using Hammer in a Phoenix application.

Available Backends

Getting Help

If you're having trouble, either open an issue on this repo

Acknowledgements

Hammer was inspired by the ExRated library, by grempe.

License

Copyright (c) 2023 June Kelly

This library is MIT licensed. See the LICENSE for details.

hammer's People

Contributors

dependabot[bot] avatar epinault avatar gazler avatar junekelly avatar njwest avatar reallinfo avatar rosswilson avatar ruslandoga avatar slashdotdash avatar sobolevn avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

hammer's Issues

Resetting Hammer.Backend.ETS in tests

Hi there,

I've integrated Hammer into my application in different places. And when I run my application tests (using Hammer.Backend.ETS), tests that run first affect the rate limits for the subsequent tests.

One way to avoid this issue would be to reset the rate limits (ETS tables) before each test, but I couldn't find anything built into Hammer to achieve that.

Another alternative would be to create a mock of Hammer for my tests with resetting capabilities, but this would be too complex as I'd basically be creating an in-memory version of Hammer.

To get my tests to work, for now I've set expiry_ms to 0 (only for tests), and I've created a little test helper that I call in a setup block so that it resets all rate limits before each test:

  def reset_rate_limits() do
    [{_, pool_pid, _, _}] = Supervisor.which_children(Hammer.Supervisor)
    {:state, _, children_pids, _, _, _, _, _, _} = :sys.get_state(pool_pid)
    Enum.each(children_pids, &Process.send(&1, :prune, []))
  end

This solution feels too hacky, though.
Is there another built-in way to solve my problem that I haven't realized yet?

Thanks in advance and thanks for the great library!

More Backends

We should have more backends, because this project aims to be maximally useful in a variety of architectures. Perhaps:

  • Mnesia
  • Postgres (or a generic SQL backend? Probably Ecto)
  • MongoDB
  • ???

Crash on Application startup with `String.to_existing_atom `

Hammer.Supervisor fails to start because it assumes the hammer backend names already exist in the atom pool.

Easily reproducible with running the following in iex:

Application.put_env(:hammer, :backend, [default: {Hammer.Backend.ETS, [expiry_ms: 10]}])
Mix.install([:hammer])

Crashes in the following way:

20:58:30.827 [notice] Application hammer exited: Hammer.Application.start(:normal, []) returned an error: an exception was raised:
    ** (ArgumentError) errors were found at the given arguments:

  * 1st argument: not an already existing atom

        :erlang.binary_to_existing_atom("hammer_backend_default_pool", :utf8)
        (hammer 6.2.0) lib/hammer/supervisor.ex:30: anonymous fn/1 in Hammer.Supervisor.init/1
        (elixir 1.15.4) lib/enum.ex:1693: Enum."-map/2-lists^map/1-1-"/2
        (hammer 6.2.0) lib/hammer/supervisor.ex:28: Hammer.Supervisor.init/1
        (stdlib 5.0.2) supervisor.erl:330: :supervisor.init/1
        (stdlib 5.0.2) gen_server.erl:962: :gen_server.init_it/2
        (stdlib 5.0.2) gen_server.erl:917: :gen_server.init_it/6
        (stdlib 5.0.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/3

"Delayed" increment of bucket depending

I want to stop failed websocket attempts on my phoenix application, and so the bucket could be called "sockets:failed_conns-#{client_id}", for instance.

In my use case I want to delay incrementing the bucket because I do not yet know when checking whether I want to increment it. I guess I could delay it by using check_rate_inc/{4,5} by setting the increment integer to 0, when checking initially. I then want to increment and reject the websocket upgrade if the authentication fails, such that a retry will possibly deny.

Can this be done today? There is no function to just increment, is there? πŸ€” Meaning that I don't care about the {:allow, _} or {:deny, _} at this point

state of this project?

Is this still maintained? I see MR that are waiting to be merged for a long while. Do we need more maintainer?

New logo for Hammer

Hello, i am a graphic designer. I wanted to contribute to Hammer and i designed a logo for Hammer. If you like it, i can send a pr. We can also organize a logo together.

hammer

e.g. readmefile view

view

If you like it, which one do you prefer?

Backend.ETS Tests Failing?

I've just tried running the tests locally, and got the following result:

...........

  1) test timeout pruning (ETSTest)
     test/hammer_ets_test.exs:46
     match (=) failed
     code:  assert {:ok, 1} = ETS.count_hit(pid, key, stamp)
     left:  {:ok, 1}
     right: {:ok, 4}
     stacktrace:
       test/hammer_ets_test.exs:50: (test)

....

Finished in 1.1 seconds (0.00s async, 1.1s sync)
16 tests, 1 failure

Randomized with seed 111193

On each run it seems to fail with a slightly different value. I suspect something in the ETS platform has shifted over time, and we need to look into it.

I see the test is also failing in CI: https://github.com/ExHammer/hammer/actions/runs/4780564479/jobs/8498385954

EDIT: I also see the count_hit test failing on some runs too...


  2) test count_hit (ETSTest)
     test/hammer_ets_test.exs:13
     match (=) failed
     code:  assert {:ok, 1} = ETS.count_hit(pid, key, stamp)
     left:  {:ok, 1}
     right: {:ok, 2}
     stacktrace:
       test/hammer_ets_test.exs:16: (test)

Rate limit time priod not respected?

Hey,

I am trying to use this library in the following setup:

  1. Hammer
  2. Hammer plug
  3. Hammer redis backend

The plug setup is very simple, I am using the default ip option.

I am experimenting with smaller time frames, 20-30 seconds and 10 requests allowed in given timeframe.
In my test cases(uses ets) everything seems ok.

In reality it seems like something is off, could be my config.

If I put in let's say this plug config:

  plug Hammer.Plug,
       [rate_limit: {"public:clip", 60_000, 3}]
       when action == :public_clip

So 3 requests in 1 minute, and then just continuously refresh the page the limit is not being applied, same with new page loads.

I checked in redis and it seems there are multiple keys when I would expect 1, which explains why the limit is sometimes lifted, but I don't understand why.

image

This is what I mean. 1 ip but multiple entries with the part after the ip making the key different.

My Hammer config:

config :hammer,
  backend:
    {Hammer.Backend.Redis,
     [
       expiry_ms: 60_000 * 60 * 14,
       redis_url: redis_url,
       pool_size: 4,
       pool_max_overflow: 2
     ]}

Any ideas what is causing this?

Thank you

Add credo

I use the standard credo config.
Do you have any specific preferences?

I will provide a PR shortly.

Burst limit

Is there any interest in adding support for burst? No worries if not but if so my company is using hammer in production and would be happy to contribute a PR adding this feature.

This would be very similar to how nginx works https://www.nginx.com/blog/rate-limiting-nginx/ but the idea is in practice requests are not uniformly distributed so with just a rate limit you have to set the limit to a high level to avoid false positives. With bursts you can set it at a more realistic level but use the burst to catch the false positives.

# Proposed
case Hammer.check_rate("upload_video:#{user_id}", 60_000, 5, 20) do
  {:allow, _count} ->
    # upload the video, somehow
  {:deny, _limit} ->
    # deny the request
end

Edit: Actually I was mistaken earlier this cannot be done with two rate limit checks.

Occasional test failures

Run for _n in {1..1000}; do make test; done for a while, and sometimes tests will fail with:

mix format mix.exs "lib/**/*.{ex,exs}" "test/**/*.{ex,exs}"
mix test --no-start
........

  1) test make_rate_checker (HammerTest)
     test/hammer_test.exs:18
     match (=) failed
     code:  assert {:deny, 2} = check.("aaa")
     right: {:error, %ArgumentError{message: "argument error"}}
     stacktrace:
       test/hammer_test.exs:22: (test)

......

Finished in 1.4 seconds
15 tests, 1 failure

Randomized with seed 326640

and

  1) test returns expected tuples on delete_buckets (HammerTest)
     test/hammer_test.exs:70
     match (=) failed
     code:  assert {:allow, 1} = Hammer.check_rate("my-bucket1", 1000, 2)
     right: {:deny, 2}
     stacktrace:
       test/hammer_test.exs:71: (test)

and

  1) test returns expected tuples on inspect_bucket (HammerTest)
     test/hammer_test.exs:58
     match (=) failed
     code:  assert {:deny, 2} = Hammer.check_rate("my-bucket1", 1000, 2)
     right: {:allow, 1}
     stacktrace:
       test/hammer_test.exs:65: (test)

............

Finished in 1.3 seconds
15 tests, 1 failure

performance degradation over time with ETS backend

Describe the bug
I've been benchmarking hot paths in https://github.com/plausible/analytics and noticed that ExHammer check here becomes slower over time, from 11us in the beginning of k6 benchmark (it hits the same site every time, so :hammer_ets_buckets has a single key throughout the benchmark) to over 100ms in the end. And in some requests (when user-agents are cached) that check_rate call becomes the largest slice in the request flamegraph

Screenshot 2023-11-22 at 19 28 43

** Provide the following details

  • Elixir version (elixir -v): 1.15.7
  • Erlang version (erl -v): 26.1.2
  • Operating system: mac sonoma

How to use Hammer to count the number of times rate-limits are reached within an interval

Not a bug per se, I just wanted to see if anyone had thoughts.

We started using Hammer to deal with some spammers who have been using our app to send lots of emails. It works well so far, as in the amount of damage spammers cause is now limited (e.g. each IP address can now send X emails per minute, after which they get 429'd).

We want to take this one step further though, by adding an IP address to a blacklist if requests coming from it are rate-limited more than X times within a given interval Y. In other words, it's like rate-limiting, but at a "meta" level.

Is there a way to utilize Hammer for this purpose?

v2 API and Spec

As we've discussed in #9, the current API (use Hammer, backend: Whatever) is not ideal. Frankly, I lashed it together quickly for the sake of getting V1 out the door.

In this issue I will document my thoughts on an API for v2, and we can discuss pros/cons/etc.

Allow multiple backends?

I am writing an API server (built on phoenix) which proxies API requests to multiple backends. I need to rate-limit globally, per-client, and per-backend.
For the global, and per-client case I can use the ETS backend, but for the per-backend rate-limiting I would like to keep the info in Redis, to be usable across multiple instances of my app.

Is that possible?

BTW, using Redis would incur an extra round-trip to Redis on every request right?

New `Memory` backend, replace ETS?

We seem to have a few issues with ETS backends, that are hard to make any better. Maybe a custom map-based storage server as the new default?

Specifying backends via config

Right now it is only possible to specify a backend via keyword argument.
I think, that it would be a nice feature to implement basic mix config for this task.

Something like:

use Mix.Config

config :hammer, Hammer,
   backend: Hammer.Backend.ETS

Investigate Bottlenecks

I've got this weird feeling that we may be introducing a bottleneck by passing all rate-checks through one backend process. Performance seems pretty good in my tests, but I'd rather not be dragging down the throughput of applications unnecessarily.

Actions:

  • investigate worker-pool options
  • experiment with making the backends a worker pool
  • decide what to do

Using Hammer for ratelimiting external API calls

This is more of a question than an issue with the library - I'm currently attempting to use Hammer to ratelimit access to an external API to avoid hitting ratelimits on the API itself. Other libraries I found require the ratelimits to be declared upfront somehow and the possibility to create a rate limit checker seemed appropriate (since I get the rate limits returned in HTTP headers) so I went with Hammer.

However, since the API starts counting ratelimits after my first request, and Hammer's rate limits move in "windows" due to stamp_key/2...:

# Returns tuple of {timestamp, key}, where key is {bucket_number, id}
def stamp_key(id, scale_ms) do
stamp = timestamp()
# with scale_ms = 1 bucket changes every millisecond
bucket_number = trunc(stamp / scale_ms)
key = {bucket_number, id}
{stamp, key}
end

... this doesn't quite work out, namely because Hammer's buckets are almost guaranteed to expire before an API bucket expires. What would you recommend to do in this case instead? Perhaps it would be possible to allow for using a custom "initial start" for stamp_key?

Thanks for creating this library, by the way. I really like the configurability of backends and being able to extend those if needed.

Allow all key types

Hi,

One improvement idea: allowing any key type (any erlang term) for keys, and letting the backend to deal with it if unsupported. For instance, if I want to throttle an API on IP address, machine id and and user id, I might want to store it directly as a key ([{122, 12, 243,109}, "device_382719", "edward03"]) or a hash of it (:erlang.phash2([{122, 12, 243,109}, "device_382719", "edward03"]) which returns an integer).

ETS and Mnesia could store both forms as-is (not sure about the performances when storing the non-hashed key though, guess it depends on the length). As of today, the mandatory conversion to a binary might unnecessarily degrade performance.

Hammer application logs on start

Hello!

The hammer application logs on start. In production this could possible be useful, but in dev and test it is unneeded noise that makes it harder to read test results and see deprecation warnings.

Would it be possible to remove this logging, or to make it configurable, or to only do it in the production mix env?

Thanks,
Louis

Runtime configuration?

I want to use hammer via redis, but if I configure it as in the docs, I have to specify my redis host and password at compile time, which I absolutely don't want to do.

What I ended up having to do was, to prevent hammer from starting itself.

{:hammer, "~> 6.0.0", runtime: false},
{:hammer_plug, "~> 2.1", runtime: false},
{:hammer_backend_redis, "~> 6.1", runtime: false}

Add a dummy config so that when it picks a pool, it knows what its name will be.

config :hammer,
  backend: {Hammer.Backend.Redis, nil}

Add it manually to my supervision tree at runtime with the config of my choice

defmodule MyApp.Hammer do
  def child_spec do                                                                            
    %{                                                                                         
      id: Hammer.Supervisor,                                                                   
      start:
        {Hammer.Supervisor, :start_link,                                                       
         [                                                                                     
           {Hammer.Backend.Redis,                                                              
            [
              expiry_ms: 60_000,                                                               
              redix_config: [                                                                  
                host: System.get_env("REDIS_HOST") || raise("env var REDIS_HOST required"),    
                password: System.get_env("REDIS_PASS") || raise("env var REDIS_PASS required") 
              ]
            ]},
           [name: Hammer.Supervisor]                                                           
         ]}
    } 
  end                                                                                          
end

defmodule MyApp.Supervisor do
  def init(_args) do
    processes = [
       ...
       MyApp.Hammer
    ]  |> Supervisor.init(...)
  end
end

Is there an easier way of doing what I just did?

Is it necessary to use pool for operate backend ?

I noticed that the Hammer module use poolboy to call backend and I don't think it is a good idea, because:

1, ETS update_counter operation need not worker pool protection, we can use http://erlang.org/doc/man/ets.html#update_counter-4

2, It will increase overload to use the pool.

3, Put the ETS operation into GenServer loop will increase the risk of operation timeout, one operation will through twice (actually three times) send the message

 user process ---> pool manager process ---> worker process
     ^                                           |
     |-------------------------------------------√

In particular, it will scan the whole ETS table when using select_delete to prune and delete_buckets, and it is quite possible to trigger a timeout.

4, For Redis backend, it is not necessary either since there is a pool in front of Redis-server in Redis driver.

Thanks for your time.

A bit more explanation on why ETS is bad in production?

Hammer.Backend.ETS (provided with Hammer for testing and dev purposes, not very good for production use)

Sorry, I was just wondering what are the reasons why ETS is "not very good" for production use?

In your docs you also say:

There may come a time when ETS just doesn’t cut it, for example if we end up load-balancing across many nodes and want to keep our rate-limiter state in one central store.

That suggests it may be fine in production, depending on the circumstances.

I think it would be useful for users to know the reasons it is not recommended in the README, so they can weigh their decisions of if they should start with ETS and switch to Redis later, and help decide when they should plan on making the switch... or just not consider it at all.

Thanks so much :)

String.to_existing_atom failing in utils.ex

Describe the bug
When calling Hammer functions with a single backend, NOT a list of backends, the call fails with:

** (exit) an exception was raised:
    ** (ArgumentError) errors were found at the given arguments:

  * 1st argument: not an already existing atom

        :erlang.binary_to_existing_atom("hammer_backend_ets_pool", :utf8)
        (hammer 6.2.0) lib/hammer/utils.ex:9: Hammer.Utils.pool_name/1
        (hammer 6.2.0) lib/hammer.ex:255: Hammer.call_backend/3
        (hammer 6.2.0) lib/hammer.ex:51: Hammer.check_rate/4

** Provide the following details

  • Elixir version (elixir -v): Elixir 1.15.6
  • Erlang version (erl -v): Erlang/OTP 26
  • Operating system: macOS 14.2

Expected behavior
I expect Hammer.check_rate/4 to not result in an error

Actual behavior
See above

ArgumentError from Hammer.check_rate/3

I met ArgumentError frequently.

** (CaseClauseError) no case clause matching: {:error, %ArgumentError{message: "argument error"}}

When call_backend/3 returns {:error, _}, check_rate/4 returns {:error, _}.

I guess it's because of lack of pool.

Add the hits parameters

Some solutions has the concept of query complexity/cost and I'd like to this value to rate limite the customer.

If change the api to add the hits parameter will be possible to use hammer to rate limit

  @spec check_rate(id :: String.t(), scale_ms :: integer, limit :: integer, hits :: integer) ::
          {:allow, count :: integer}
          | {:deny, limit :: integer}
          | {:error, reason :: any}
  def check_rate(id, scale_ms, limit, hits \\ 1) do
    check_rate(:single, id, scale_ms, limit, hits)
  end

Does that make sense to you?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.