Spaces:
Running
Running
Download rate_limit.py from ZHIWEI666/ComfyUI-Ranking-API: direct link, hf CLI and curl.
- Browser
- Download file 14.2 kB
-
https://huggingface.co/spaces/ZHIWEI666/ComfyUI-Ranking-API/resolve/main/rate_limit.py
- Command line
-
hf download hf://spaces/ZHIWEI666/ComfyUI-Ranking-API/rate_limit.py
-
curl -L -o rate_limit.py https://huggingface.co/spaces/ZHIWEI666/ComfyUI-Ranking-API/resolve/main/rate_limit.py
14.2 kB
| """跨 worker API 限流:已验证用户按账号计数,匿名请求按客户端 IP 计数。""" | |
| from datetime import datetime | |
| from functools import wraps | |
| from inspect import iscoroutinefunction | |
| import hashlib | |
| import hmac | |
| import logging | |
| import secrets | |
| import threading | |
| import time | |
| from fastapi import Request | |
| from fastapi import HTTPException | |
| from auth_io import run_identity_io | |
| from owned_thread import OwnedThreadGroup | |
| from sqlalchemy import case, delete | |
| from sqlalchemy.dialects.mysql import insert as mysql_insert | |
| from sqlalchemy.dialects.postgresql import insert as postgresql_insert | |
| from sqlalchemy.dialects.sqlite import insert as sqlite_insert | |
| from 安全认证 import get_account_from_token | |
| from database_sql import SessionLocal, engine | |
| from models_sql import ApiRateLimitBucket, VerificationCode, VerificationContactCooldown | |
| from 安全认证 import JWT_SECRET | |
| logger = logging.getLogger("ComfyUI-Ranking.RateLimit") | |
| _cleanup_lock = threading.Lock() | |
| _last_cleanup_at = 0.0 | |
| MAX_SHARED_WORK = 40 | |
| _shared_work = OwnedThreadGroup(MAX_SHARED_WORK, | |
| lambda: HTTPException(status_code=503, detail='限流服务暂不可用')) | |
| run_shared_work = _shared_work.run | |
| start_shared_work = _shared_work.start | |
| stop_shared_work = _shared_work.stop | |
| close_shared_work = _shared_work.close | |
| def get_remote_address(request: Request) -> str: | |
| """仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。""" | |
| client = getattr(request, "client", None) | |
| return getattr(client, "host", None) or "unknown" | |
| def get_rate_limit_key(request: Request) -> str: | |
| """返回稳定的限流身份键;只读取并验证 Bearer Token,不记录或返回 Token。""" | |
| authorization = request.headers.get("Authorization", "") | |
| scheme, _, token = authorization.partition(" ") | |
| if scheme.lower() == "bearer" and token.strip(): | |
| account = get_account_from_token(token.strip()) | |
| if account: | |
| return f"account:{account.casefold()}" | |
| return f"ip:{get_remote_address(request)}" | |
| def _consume_shared_limit(identity: str, scope: str, limit: int) -> None: | |
| """使用数据库原子 UPSERT 计数;数据库不可用时 fail closed。""" | |
| now = int(time.time()) | |
| window_start = now - (now % 60) | |
| bucket_key = hashlib.sha256(f"{identity}\0{scope}".encode("utf-8")).hexdigest() | |
| session = None | |
| try: | |
| session = SessionLocal() | |
| dialect = engine.dialect.name | |
| values = { | |
| "bucket_key": bucket_key, | |
| "window_start": window_start, | |
| "request_count": 1, | |
| "updated_at": datetime.utcnow(), | |
| } | |
| if dialect == "sqlite": | |
| statement = sqlite_insert(ApiRateLimitBucket).values(**values) | |
| statement = statement.on_conflict_do_update( | |
| index_elements=[ApiRateLimitBucket.bucket_key], | |
| set_={ | |
| "window_start": window_start, | |
| "request_count": case( | |
| (ApiRateLimitBucket.window_start == window_start, | |
| case((ApiRateLimitBucket.request_count < limit + 1, | |
| ApiRateLimitBucket.request_count + 1), else_=limit + 1)), | |
| else_=1, | |
| ), | |
| "updated_at": datetime.utcnow(), | |
| }, | |
| ) | |
| elif dialect == "postgresql": | |
| statement = postgresql_insert(ApiRateLimitBucket).values(**values) | |
| statement = statement.on_conflict_do_update( | |
| index_elements=[ApiRateLimitBucket.bucket_key], | |
| set_={ | |
| "window_start": window_start, | |
| "request_count": case( | |
| (ApiRateLimitBucket.window_start == window_start, | |
| case((ApiRateLimitBucket.request_count < limit + 1, | |
| ApiRateLimitBucket.request_count + 1), else_=limit + 1)), | |
| else_=1, | |
| ), | |
| "updated_at": datetime.utcnow(), | |
| }, | |
| ) | |
| elif dialect == "mysql": | |
| statement = mysql_insert(ApiRateLimitBucket).values(**values) | |
| statement = statement.on_duplicate_key_update( | |
| [ | |
| # MySQL evaluates ON DUPLICATE KEY UPDATE assignments left-to-right. | |
| # Read the old window before replacing it, or a new minute would | |
| # incorrectly continue the prior minute's count. | |
| ("request_count", case( | |
| (ApiRateLimitBucket.window_start == window_start, | |
| case((ApiRateLimitBucket.request_count < limit + 1, | |
| ApiRateLimitBucket.request_count + 1), else_=limit + 1)), | |
| else_=1, | |
| )), | |
| ("window_start", window_start), | |
| ("updated_at", datetime.utcnow()), | |
| ] | |
| ) | |
| else: | |
| raise RuntimeError(f"Unsupported SQL dialect for shared rate limiting: {dialect}") | |
| session.execute(statement) | |
| bucket = session.get(ApiRateLimitBucket, bucket_key) | |
| if not bucket: | |
| raise RuntimeError("Rate limit bucket upsert did not produce a row") | |
| request_count = bucket.request_count | |
| session.commit() | |
| if request_count > limit: | |
| raise HTTPException(status_code=429, detail=f"请求过于频繁,限流 {limit}/分钟,请稍后再试") | |
| _cleanup_old_buckets_if_due() | |
| except HTTPException: | |
| if session is not None: | |
| try: session.rollback() | |
| except Exception: logger.error("SHARED_RATE_LIMIT_ROLLBACK_FAIL") | |
| raise | |
| except Exception as exc: | |
| if session is not None: | |
| try: session.rollback() | |
| except Exception: logger.error("SHARED_RATE_LIMIT_ROLLBACK_FAIL") | |
| logger.error("SHARED_RATE_LIMIT_DB_FAIL | scope=%s | error_type=%s", scope, type(exc).__name__) | |
| raise HTTPException(status_code=503, detail="限流服务暂不可用") from exc | |
| finally: | |
| if session is not None: | |
| try: session.close() | |
| except Exception as error: | |
| logger.error("SHARED_RATE_LIMIT_CLOSE_FAIL | error_type=%s", type(error).__name__) | |
| raise HTTPException(status_code=503, detail="限流服务暂不可用") from None | |
| def _cleanup_old_buckets_if_due() -> None: | |
| global _last_cleanup_at | |
| now = time.time() | |
| if now - _last_cleanup_at < 900: | |
| return | |
| if not _cleanup_lock.acquire(blocking=False): | |
| return | |
| try: | |
| if now - _last_cleanup_at < 900: | |
| return | |
| session = SessionLocal() | |
| try: | |
| session.execute(delete(ApiRateLimitBucket).where(ApiRateLimitBucket.window_start < int(now) - 86_400)) | |
| session.execute(delete(VerificationContactCooldown).where( | |
| VerificationContactCooldown.last_sent_at < int(now) - 86_400 | |
| )) | |
| session.execute(delete(VerificationCode).where(VerificationCode.expires_at < int(now))) | |
| session.commit() | |
| _last_cleanup_at = now | |
| except Exception: | |
| session.rollback() | |
| logger.warning("SHARED_RATE_LIMIT_CLEANUP_FAIL") | |
| finally: | |
| session.close() | |
| finally: | |
| _cleanup_lock.release() | |
| def shared_limit(limit: int): | |
| """FastAPI endpoint decorator backed by the shared SQL minute-window table.""" | |
| if not isinstance(limit, int) or limit < 1: | |
| raise ValueError("limit must be a positive integer") | |
| def decorate(endpoint): | |
| scope = f"{endpoint.__module__}.{endpoint.__name__}" | |
| is_async = iscoroutinefunction(endpoint) | |
| async def wrapped(*args, **kwargs): | |
| request = kwargs.get("request") | |
| if request is None: | |
| request = next((arg for arg in args if isinstance(arg, Request)), None) | |
| if request is None: | |
| raise RuntimeError(f"{scope} requires a FastAPI Request parameter") | |
| identity = await run_identity_io(get_rate_limit_key, request) | |
| await run_shared_work(_consume_shared_limit, identity, scope, limit) | |
| if is_async: | |
| return await endpoint(*args, **kwargs) | |
| return await run_shared_work(endpoint, *args, **kwargs) | |
| return wrapped | |
| return decorate | |
| def reserve_contact_cooldown(contact: str, cooldown_seconds: int) -> int: | |
| """原子预留联系方式冷却;返回 0 表示成功,否则返回剩余秒数。""" | |
| normalized = str(contact or "").strip().casefold() | |
| contact_key = hashlib.sha256(normalized.encode("utf-8")).hexdigest() | |
| now = int(time.time()) | |
| cutoff = now - cooldown_seconds | |
| claim_id = secrets.token_hex(16) | |
| session = SessionLocal() | |
| try: | |
| values = {"contact_key": contact_key, "last_sent_at": now, "claim_id": claim_id} | |
| dialect = engine.dialect.name | |
| if dialect == "sqlite": | |
| statement = sqlite_insert(VerificationContactCooldown).values(**values) | |
| statement = statement.on_conflict_do_update( | |
| index_elements=[VerificationContactCooldown.contact_key], | |
| set_={ | |
| "last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now), | |
| else_=VerificationContactCooldown.last_sent_at), | |
| "claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id), | |
| else_=VerificationContactCooldown.claim_id), | |
| }, | |
| ) | |
| elif dialect == "postgresql": | |
| statement = postgresql_insert(VerificationContactCooldown).values(**values) | |
| statement = statement.on_conflict_do_update( | |
| index_elements=[VerificationContactCooldown.contact_key], | |
| set_={ | |
| "last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now), | |
| else_=VerificationContactCooldown.last_sent_at), | |
| "claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id), | |
| else_=VerificationContactCooldown.claim_id), | |
| }, | |
| ) | |
| elif dialect == "mysql": | |
| statement = mysql_insert(VerificationContactCooldown).values(**values) | |
| statement = statement.on_duplicate_key_update( | |
| [ | |
| # Evaluate eligibility against the old timestamp before advancing it. | |
| ("claim_id", case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id), | |
| else_=VerificationContactCooldown.claim_id)), | |
| ("last_sent_at", case((VerificationContactCooldown.last_sent_at <= cutoff, now), | |
| else_=VerificationContactCooldown.last_sent_at)), | |
| ] | |
| ) | |
| else: | |
| raise RuntimeError(f"Unsupported SQL dialect for contact cooldown: {dialect}") | |
| session.execute(statement) | |
| record = session.get(VerificationContactCooldown, contact_key) | |
| if not record: | |
| raise RuntimeError("Contact cooldown upsert did not produce a row") | |
| allowed = record.claim_id == claim_id | |
| last_sent_at = record.last_sent_at | |
| session.commit() | |
| return 0 if allowed else max(1, cooldown_seconds - (now - last_sent_at)) | |
| except Exception as exc: | |
| session.rollback() | |
| logger.error("CONTACT_COOLDOWN_DB_FAIL | error_type=%s", type(exc).__name__) | |
| raise HTTPException(status_code=503, detail="验证码发送频控暂不可用") from exc | |
| finally: | |
| session.close() | |
| def _verification_key(key: str) -> str: | |
| return hashlib.sha256(str(key).strip().casefold().encode("utf-8")).hexdigest() | |
| def _verification_digest(key: str, code: str) -> str: | |
| return hmac.new(JWT_SECRET.encode("utf-8"), f"{_verification_key(key)}\0{code}".encode("utf-8"), hashlib.sha256).hexdigest() | |
| def store_verification_code(key: str, code: str, expires_at: int) -> None: | |
| """存储跨 worker 可用的一次性验证码,不保存验证码或联系方式明文。""" | |
| session = SessionLocal() | |
| try: | |
| session.merge(VerificationCode(contact_key=_verification_key(key), | |
| code_digest=_verification_digest(key, code), | |
| expires_at=int(expires_at))) | |
| session.commit() | |
| except Exception as exc: | |
| session.rollback() | |
| logger.error("VERIFICATION_CODE_STORE_FAIL | error_type=%s", type(exc).__name__) | |
| raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc | |
| finally: | |
| session.close() | |
| def consume_verification_code(key: str, code: str, session=None) -> bool: | |
| """正确且未过期时原子删除验证码;可传入业务事务 session 一并提交。""" | |
| owns_session = session is None | |
| session = session or SessionLocal() | |
| try: | |
| result = session.execute(delete(VerificationCode).where( | |
| VerificationCode.contact_key == _verification_key(key), | |
| VerificationCode.code_digest == _verification_digest(key, code), | |
| VerificationCode.expires_at >= int(time.time()), | |
| )) | |
| consumed = result.rowcount == 1 | |
| if owns_session: | |
| session.commit() | |
| return consumed | |
| except Exception as exc: | |
| if owns_session: | |
| session.rollback() | |
| logger.error("VERIFICATION_CODE_CONSUME_FAIL | error_type=%s", type(exc).__name__) | |
| raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc | |
| finally: | |
| if owns_session: | |
| session.close() | |