"""Fixed-window per-IP rate limiting for the agent-facing routes. One implementation, several budgets. The bootstrap installer routes and the enrollment route are both unauthenticated by design - a PC with no agent has neither a credential nor a user session - so the only thing standing between them and a loop is a limiter. Each caller gets its own budget name so the two surfaces cannot spend each other's allowance. Same shape and same cache extension as the login limiter in shopdb.core.api.auth. Under the default SimpleCache the counter is per-process, so with N gunicorn workers the effective budget is N times the configured maximum; that is defence in depth rather than a hard quota, and a shared cache backend (Redis/memcached) tightens it to a true global budget. """ import time from flask import current_app, request from shopdb.api import cache, client_ip, error_response def caller_ip(): return client_ip(request) or 'unknown' def ratelimited(budget, configprefix, defaultmax, defaultwindow): """True when this caller is over budget for the current window. budget cache key namespace, e.g. 'endpointenrollratelimit' configprefix config key stem, e.g. 'ENDPOINTS_ENROLL_RATELIMIT'; reads _ENABLED, _MAX and _WINDOW_SECONDS """ if not current_app.config.get(configprefix + '_ENABLED', True): return False window = current_app.config.get(configprefix + '_WINDOW_SECONDS', defaultwindow) maxhits = current_app.config.get(configprefix + '_MAX', defaultmax) # Time bucket makes this a fixed window: the key rolls over at each window # boundary, so a per-hit set() cannot turn it into a sliding window. bucket = int(time.time() // window) if window > 0 else 0 key = '{0}:{1}:{2}'.format(budget, caller_ip(), bucket) count = cache.get(key) or 0 if count >= maxhits: return True cache.set(key, count + 1, timeout=window) return False def rate_limit_response(message): """The standard 429 shape every limiter in the product answers with.""" return error_response('RATE_LIMITED', message, http_code=429)