429 限流加 Retry-After 头:值为限流窗口秒数,不改阈值与计数
- _send_json 加 extra_headers 参数,429 返回处传 Retry-After = limiter.window_seconds(默认 60),客户端据此退避一个完整窗口, 不再以原速率 hammer 把自己钉在限流里(Postgres DbRateLimiter 先 INSERT 再 COUNT,被拒请求本身也计数)。 - 单测:429 响应含 Retry-After 且值等于窗口秒数(默认 60 与注入 窗口 7 各验一次)。 - 相关单测:test_prod_webhook_exec + test_rate_limit_db 21 个、 test_server + test_server_endpoints 28 个,全过;cloud 全量未跑。
This commit is contained in:
1 parent
eae9884b13
commit
c43eaac4d3
2 files changed
+41
-2
No files matched your search
+12
-2
@@ -1597,11 +1597,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): # 测试输出保持干净
|
||||
pass
|
||||
|
||||
def _send_json(self, status, obj):
|
||||
def _send_json(self, status, obj, extra_headers=None):
|
||||
body = json.dumps(obj, default=_json_default).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
for header_name, header_value in (extra_headers or {}).items():
|
||||
self.send_header(header_name, header_value)
|
||||
self.end_headers()
|
||||
# 发头即算已响应:之后 wfile 写失败(客户端已断)也不应再
|
||||
# 尝试回 500——响应一旦开始就不能再发第二个状态行。
|
||||
@@ -1847,7 +1849,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
limit_key = "ip:" + (self.client_address[0]
|
||||
if self.client_address else "unknown")
|
||||
if not limiter.allow(limit_key):
|
||||
self._send_json(429, {"error": "rate_limited"})
|
||||
# 429 限流:只加 Retry-After 头(值为限流窗口秒数,
|
||||
# 即 limiter.window_seconds,默认 60),不改阈值、
|
||||
# 不改计数逻辑、不加白名单。客户端据此退避一个完整
|
||||
# 窗口,而不是以原速率继续 hammer 把自己钉在限流里
|
||||
# (Postgres 下 DbRateLimiter 先 INSERT 再 COUNT,
|
||||
# 被拒请求本身也计入窗口,这是 abuse 缓解的本意)。
|
||||
self._send_json(
|
||||
429, {"error": "rate_limited"},
|
||||
extra_headers={"Retry-After": str(limiter.window_seconds)})
|
||||
return
|
||||
|
||||
user_id = self._authenticate()
|
||||
|
||||
@@ -255,6 +255,35 @@ class WebhookExecTestCase(unittest.TestCase):
|
||||
self.assertEqual(status, 429)
|
||||
self.assertEqual(body, {"error": "rate_limited"})
|
||||
|
||||
def test_rate_limit_429_has_retry_after_window_seconds(self):
|
||||
# 429 响应必须含 Retry-After 头,且值为限流窗口秒数
|
||||
# (limiter.window_seconds),不是硬编码 60:先用默认
|
||||
# 窗口 60 验一次,再用注入窗口 7 验一次。
|
||||
for window, per_minute in ((60, 1), (7, 1)):
|
||||
self.server.rate_limiter = RateLimiter(
|
||||
per_minute=per_minute, window_seconds=window)
|
||||
self.server.rate_limit_per_minute = per_minute
|
||||
self.server.rate_limit_window_seconds = window
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
try:
|
||||
conn.request("GET", "/projects",
|
||||
headers={"X-API-Key": self.owner_key})
|
||||
resp = conn.getresponse()
|
||||
resp.read()
|
||||
self.assertEqual(resp.status, 200)
|
||||
conn.request("GET", "/projects",
|
||||
headers={"X-API-Key": self.owner_key})
|
||||
resp = conn.getresponse()
|
||||
body = resp.read()
|
||||
self.assertEqual(resp.status, 429)
|
||||
self.assertEqual(json.loads(body.decode("utf-8")),
|
||||
{"error": "rate_limited"})
|
||||
self.assertEqual(resp.getheader("Retry-After"),
|
||||
str(window))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_health_backend_and_db_ok_and_metrics_by_status(self):
|
||||
status, body = self._req("GET", "/health")
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
Reference in new issue
Block a user