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
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 )