Skip to content

Instantly share code, notes, and snippets.

@HurricanKai
Created July 18, 2022 14:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save HurricanKai/746b8ad51a473652faec3c08580abba0 to your computer and use it in GitHub Desktop.
Save HurricanKai/746b8ad51a473652faec3c08580abba0 to your computer and use it in GitHub Desktop.

Riot Rate Limiter

Mix.install([
  {:tesla, "~> 1.4"},
  {:jason, "~> 1.3"},
  {:hackney, "~> 1.18"}
])

Simply Greedy Ratelimiter

defmodule GreedyRatelimiter do
  defstruct [:count, :limit, :seconds, :start_time]

  def new(count, limit, seconds, start_time) do
    %__MODULE__{count: count, limit: limit, seconds: seconds, start_time: start_time}
  end

  def new_empty(limit, seconds) do
    %__MODULE__{count: 0, limit: limit, seconds: seconds, start_time: DateTime.now!("Etc/UTC")}
  end

  def reserve(state) do
    to_wait =
      state.seconds * 1000 -
        DateTime.diff(DateTime.now!("Etc/UTC"), state.start_time, :millisecond)

    new_state =
      if to_wait <= 0 do
        new_empty(state.limit, state.seconds)
      else
        state
      end

    if new_state.count < new_state.limit do
      {:ok, %__MODULE__{new_state | count: new_state.count + 1}}
    else
      {:error, to_wait, new_state}
    end
  end

  def reserve_all([]) do
    {:ok, []}
  end

  def reserve_all(ratelimiters) do
    # because we just discard the whole list of new ratelimiters when one fails
    # this automatically rolls back any changes we may have made
    # immutability is great!
    Enum.reduce_while(ratelimiters, {:ok, []}, fn ratelimiter, {:ok, acc} ->
      case GreedyRatelimiter.reserve(ratelimiter) do
        {:ok, new_ratelimiter} -> {:cont, {:ok, [new_ratelimiter | acc]}}
        {:error, to_wait, _new_ratelimiter} -> {:halt, {:error, to_wait}}
      end
    end)
  end
end

Parsing HTTP Headers

defmodule RiotRateLimitInfo do
  defstruct [:count, :limit, :interval_seconds]

  def from_http_headers(headers) do
    headers = if is_map(headers), do: headers, else: Map.new(headers)
    app_rate_limit_string = Map.get(headers, "x-app-rate-limit")
    app_rate_limit_count_string = Map.get(headers, "x-app-rate-limit-count")
    method_rate_limit_string = Map.get(headers, "x-method-rate-limit")
    method_rate_limit_count_string = Map.get(headers, "x-method-rate-limit-count")

    parsed_app_rate_limit = parse_http_header(app_rate_limit_string)
    parsed_app_rate_limit_count = parse_http_header(app_rate_limit_count_string)

    parsed_method_rate_limit = parse_http_header(method_rate_limit_string)
    parsed_method_rate_limit_count = parse_http_header(method_rate_limit_count_string)

    app = combine_limit_and_count(parsed_app_rate_limit, parsed_app_rate_limit_count)
    method = combine_limit_and_count(parsed_method_rate_limit, parsed_method_rate_limit_count)

    {app, method}
  end

  defp combine_limit_and_count(limits, counts) do
    limits
    |> Enum.map(fn {limit, limit_seconds} ->
      {count, _} =
        Enum.find(counts, fn {_count, count_seconds} -> count_seconds == limit_seconds end)

      %__MODULE__{count: count, limit: limit, interval_seconds: limit_seconds}
    end)
  end

  defp parse_http_header(value) do
    String.split(value, ",")
    |> Enum.map(fn limit ->
      String.split(limit, ":")
      |> Enum.map(fn s ->
        {i, _rest} = Integer.parse(s)
        i
      end)
      |> List.to_tuple()
    end)
  end
end

Simple Ratelimit on HTTP requests using Tesla

defmodule RiotHttpLimiter do
  defstruct [:ratelimiters]

  def request(app_limiters, method_limiters, client, url, opts \\ []) do
    case {GreedyRatelimiter.reserve_all(app_limiters),
          GreedyRatelimiter.reserve_all(method_limiters)} do
      {{:ok, new_app_ratelimiters}, {:ok, new_method_ratelimiters}} ->
        case Tesla.get(client, url, opts) do
          {:ok, response} ->
            {parsed_app, parsed_method} = RiotRateLimitInfo.from_http_headers(response.headers)
            final_app = apply_infos(new_app_ratelimiters, parsed_app)
            final_method = apply_infos(new_method_ratelimiters, parsed_method)
            {:ok, response, final_app, final_method}

          _ ->
            {:tesla_error}
        end

      {{:error, i1}, {:error, i2}} when is_integer(i1) and is_integer(i2) ->
        if i1 > i2 do
          {:error, :app_limit, i1}
        else
          {:error, :method_limit, i2}
        end

      {{:error, i}, _} when is_integer(i) ->
        {:error, :app_limit, i}

      {_, {:error, i}} when is_integer(i) ->
        {:error, :method_limit, i}

      _ ->
        {:error}
    end
  end

  defp apply_infos(ratelimiters, infos) do
    infos
    |> Enum.map(fn %{count: count, limit: limit, interval_seconds: seconds} ->
      found =
        Enum.find(ratelimiters, nil, fn %{seconds: s, limit: l} -> seconds == s and limit == l end)

      case found do
        nil -> GreedyRatelimiter.new(count, limit, seconds, DateTime.now!("Etc/UTC"))
        found -> %GreedyRatelimiter{found | count: count}
      end
    end)
  end

  def request_with_wait(app_limiters, method_limiters, client, url, opts \\ []) do
    case request(app_limiters, method_limiters, client, url, opts) do
      {:ok, response, final_app, final_method} ->
        {:ok, response, final_app, final_method}

      {:error, _, i} when is_integer(i) ->
        Process.sleep(i)
        request_with_wait(app_limiters, method_limiters, client, url, opts)

      _ ->
        {:error}
    end
  end
end
defmodule RiotClient do
  def new(riot_api_token, region, adapter \\ nil) do
    middleware = [
      Tesla.Middleware.DecompressResponse,
      {Tesla.Middleware.BaseUrl, "https://" <> Atom.to_string(region) <> ".api.riotgames.com/"},
      Tesla.Middleware.JSON,
      {Tesla.Middleware.Headers, [{"X-Riot-Token", riot_api_token}]}
    ]

    adapter =
      if adapter do
        adapter
      else
        {Tesla.Adapter.Hackney, [recv_timeout: 5_000]}
      end

    Tesla.client(middleware, adapter)
  end
end

Test HTTP Ratelimiting

client = RiotClient.new("RGAPI-4acf507f-da47-4b17-8226-877988aa879c", :euw1)

1..250
|> Enum.reduce_while({[], [], []}, fn _, {responses, app, method} ->
  case RiotHttpLimiter.request_with_wait(
         app,
         method,
         client,
         "/lol/summoner/v4/summoners/by-name/INS%20HurricanKai"
       ) do
    {:ok, response, new_app, new_method} ->
      {:cont, {[response | responses], new_app, new_method}}

    reason ->
      IO.inspect(reason)
      {:halt, {responses, app, method}}
  end
end)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment