Skip to content

Add Retry-After to rate-limited responses - #771

Open
iacobdaniel wants to merge 1 commit into
mainfrom
feat/retry-after-header
Open

iacobdaniel wants to merge 1 commit into
mainfrom
feat/retry-after-header

Conversation

@iacobdaniel

Copy link
Copy Markdown
Contributor

Rate-limited requests now get a Retry-After header on Zen's 429 from the Flask, Django and ASGI middlewares, and should_block_request() returns retry_after_seconds so custom middleware can set it too. Blocked requests also count in the rate-limit window, so the delay is measured from the max_requests-th newest request in that window: a client that waits Retry-After seconds is let through, and one second earlier it is still blocked. RateLimiter.is_allowed() is renamed to check(), because it now returns the delay with the decision instead of a bool, and the QA test for Retry-After is no longer skipped.

self.rate_limited_items = LRUCache(max_items, time_to_live_in_ms)

def is_allowed(self, key, window_size_in_ms, max_requests):
def check(self, key, window_size_in_ms, max_requests):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RateLimiter.check starts with check, which obscures that it both records a request and returns a rate-limit decision.

Details

✨ AI Reasoning
​The new method is named check, which starts with the discouraged prefix. The name does not communicate whether the method only evaluates a decision or also records request timestamps; its implementation does both.

🔧 How do I fix it?
Replace 'check' with more descriptive verbs that indicate the function's action or purpose. Use 'validate' for validation, 'get' for retrieval, or 'is' for boolean checks. Ensure the name clearly communicates the function's intent and return type.

Reply on this comment to give feedback or ignore the issue.
More info

)
if not allowed:
return {"block": True, "trigger": "group"}
if not result["allowed"]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The group, user, and IP branches repeat the same blocked-result construction, including retry_after_seconds. Consolidate the shared handling so changes to this response stay consistent across all three paths.

Details

✨ AI Reasoning
​The group, user, and IP branches each inspect the same allowed result and construct the same blocked response shape, differing only in the key and trigger. This repeats the newly added retry-delay handling and response construction, so consolidating that shared behavior would reduce the chance that one path is updated differently.

🔧 How do I fix it?
Delete extra code. Extract repeated code sequences into reusable functions or methods. Use loops or data structures to eliminate repetitive patterns.

Reply on this comment to give feedback or ignore the issue.
More info

Comment on lines +47 to +56
# Blocked requests are stored too, so this timestamp, not the oldest one, must
# leave the window first. max() keeps the index valid when maxRequests < 1.
blocking_timestamp = request_timestamps[-max(max_requests, 1)]

# A timestamp exactly one window old still counts, so the wait must be longer.
retry_after_ms = blocking_timestamp + window_size_in_ms - current_time
return {
"allowed": False,
"retry_after_seconds": max(1, retry_after_ms // 1000 + 1),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low - Zero-request limits advertise a retry that can never succeed

When the service supplies an endpoint with maxRequests set to zero, every request is denied by the new limiter. check nevertheless returns a finite Retry-After, and after that delay the window is empty but the new request is still denied, so clients following the advertised delay can never get through.

Show fix
Suggested change
# Blocked requests are stored too, so this timestamp, not the oldest one, must
# leave the window first. max() keeps the index valid when maxRequests < 1.
blocking_timestamp = request_timestamps[-max(max_requests, 1)]
# A timestamp exactly one window old still counts, so the wait must be longer.
retry_after_ms = blocking_timestamp + window_size_in_ms - current_time
return {
"allowed": False,
"retry_after_seconds": max(1, retry_after_ms // 1000 + 1),
}
if max_requests < 1:
return {"allowed": False, "retry_after_seconds": 0}
# Blocked requests are stored too, so this timestamp, not the oldest one, must
# leave the window first.
blocking_timestamp = request_timestamps[-max_requests]
# A timestamp exactly one window old still counts, so the wait must be longer.
retry_after_ms = blocking_timestamp + window_size_in_ms - current_time
return {
"allowed": False,
"retry_after_seconds": max(1, retry_after_ms // 1000 + 1),
}

More info - Reply on this comment to give feedback or ignore the issue.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant