Commit b1c223d5 authored by Quang-Minh Nguyen's avatar Quang-Minh Nguyen

Add RateLimit-* headers to throttled HTTP responses

Issue: https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/731
parent 5f003f9d
......@@ -13,12 +13,8 @@ module Gitlab
# This is Rack::Attack::DEFAULT_THROTTLED_RESPONSE, modified to allow a custom response
Rack::Attack.throttled_response = lambda do |env|
# Send the Retry-After header so clients (e.g. python-gitlab) can make good choices about delays
match_data = env['rack.attack.match_data']
now = match_data[:epoch_time]
retry_after = match_data[:period] - (now % match_data[:period])
[429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, [Gitlab::Throttle.rate_limiting_response_text]]
throttled_headers = Gitlab::RackAttack.throttled_response_headers(env['rack.attack.match_data'])
[429, { 'Content-Type' => 'text/plain' }.merge(throttled_headers), [Gitlab::Throttle.rate_limiting_response_text]]
end
# Configure the throttles
......@@ -27,6 +23,54 @@ module Gitlab
configure_user_allowlist
end
# Rate Limit HTTP headers are not standardized anywhere. This is the latest
# draft submitted to IETF:
# https://github.com/ietf-wg-httpapi/ratelimit-headers/blob/main/draft-ietf-httpapi-ratelimit-headers.md
#
# This method implement the most viable parts of the headers. Those headers
# will be sent back to the client when it gets throttled.
#
# - RateLimit-Limit: indicates the request quota associated to the client
# in 60 seconds. The time window for the quota here is supposed to be
# mirrored to throttle_*_period_in_seconds application settings. However,
# our HAProxy as well as some ecosystem libraries are using a fixed
# 60-second window. Therefore, the returned limit is approximately rounded
# up to fit into that window.
#
# - RateLimit-Observed: indicates the current request amount associated to
# the client within the time window.
#
# - RateLimit-Remaining: indicates the remaining quota within the time
# window. It is the result of RateLimit-Limit - RateLimit-Remaining
#
# - Retry-After: the remaining duration in seconds until the quota is
# reset. This is a standardized HTTP header:
# https://tools.ietf.org/html/rfc7231#page-69
#
# - RateLimit-Reset: Similar to Retry-After.
#
# - RateLimit-ResetTime: the point of time that the quest quota is reset.
def self.throttled_response_headers(match_data)
# Match data example:
# {:discriminator=>"127.0.0.1", :count=>12, :period=>60 seconds, :limit=>1, :epoch_time=>1609833930}
# Source: https://github.com/rack/rack-attack/blob/v6.3.0/lib/rack/attack/throttle.rb#L33
period = match_data[:period]
limit = match_data[:limit]
rounded_limit = (limit * 60.seconds / match_data[:period]).ceil
observed = match_data[:count]
now = match_data[:epoch_time]
retry_after = period - (now % period)
reset_time = now + (period - now % period)
{
'RateLimit-Limit' => rounded_limit.to_s,
'RateLimit-Observed' => observed.to_s,
'RateLimit-Remaining' => (limit > observed ? limit - observed : 0).to_s,
'RateLimit-Reset' => retry_after.to_s,
'RateLimit-ResetTime' => Time.at(reset_time).httpdate,
'Retry-After' => retry_after.to_s
}
end
def self.configure_user_allowlist
@user_allowlist = nil
user_allowlist
......
......@@ -31,6 +31,12 @@ module RackAttackSpecHelpers
expect(response).to have_gitlab_http_status(:too_many_requests)
expect(response).to have_header('Retry-After')
expect(response).to have_header('RateLimit-Limit')
expect(response).to have_header('RateLimit-Observed')
expect(response).to have_header('RateLimit-Remaining')
expect(response).to have_header('RateLimit-Reset')
expect(response).to have_header('RateLimit-ResetTime')
expect(response).to have_header('Retry-After')
end
def expect_ok(&block)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment