diff --git a/cloud/server.py b/cloud/server.py index 9b39570..c9c812c 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -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() diff --git a/cloud/tests/test_prod_webhook_exec.py b/cloud/tests/test_prod_webhook_exec.py index 06523af..d86be3b 100644 --- a/cloud/tests/test_prod_webhook_exec.py +++ b/cloud/tests/test_prod_webhook_exec.py @@ -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)