Rate Limiting and Concurrency Control#
Dify enforces per-app concurrent request quotas using a Redis-backed RateLimit class. Each app gets a cap on how many in-flight requests it can have simultaneously. When the limit is exceeded, AppInvokeQuotaExceededError is raised immediately β there is no queuing.
Entry points:
api/core/app/features/rate_limiting/rate_limit.pyβ theRateLimitclassapi/services/app_generate_service.pyβ whereRateLimitis instantiated and applied for every app invocationapi/configs/feature/__init__.pyβ the two global config knobs
Redis Data Model#
RateLimit uses two Redis keys per client_id (the app UUID) :
| Key | Type | Purpose |
|---|---|---|
dify:rate_limit:{id}:max_active_requests | String | Configured quota; TTL refreshed every 24 h |
dify:rate_limit:{id}:active_requests | Hash | Maps request_id β timestamp for each in-flight request |
Each request gets a UUID key inside the hash. The hash length (HLEN) is the live concurrency count. Requests that exceed _REQUEST_MAX_ALIVE_TIME (10 minutes) are evicted during periodic flush_cache runs .
Request Lifecycle#
RateLimit is a per-process singleton keyed on client_id . The full flow per request:
enter(request_id)β readsHLENof the active-requests hash. Ifβ₯ max_active_requests, raisesAppInvokeQuotaExceededError; otherwise callsHSETto record the request .exit(request_id)β callsHDELto remove the request from the hash .- For streaming responses,
exitis deferred:RateLimitGeneratorwraps the generator and callsexitonly when the generator is exhausted or closed . - For workflow/advanced-chat streaming,
rate_limit_context(acontextmanager) covers the dispatch phase, after which theRateLimitGeneratorwrapper holds the slot for the lifetime of the event stream .
In AppGenerateService._run_with_guardrails, quota billing and rate limiting are coordinated: billing is committed only after rate_limit.enter() succeeds, and refunded on any exception .
Quota Configuration#
_get_max_active_requests resolves the effective limit from two config values :
APP_DEFAULT_ACTIVE_REQUESTSβ per-app default; defaults to0(unlimited)APP_MAX_ACTIVE_REQUESTSβ global ceiling; defaults to0(unlimited)
The logic takes the minimum of non-zero values. If both are 0, the limit is disabled entirely (RateLimit.disabled() returns True and all requests receive the sentinel _UNLIMITED_REQUEST_ID).
Apps can also override the limit via app.max_active_requests on the App model .
TOCTOU Race Condition in flush_cache#
The original flush_cache implementation had a Time-of-Check-Time-of-Use (TOCTOU) bug:
# Vulnerable pattern (before fix):
if redis_client.exists(max_active_requests_key): # key present hereβ¦
val = redis_client.get(max_active_requests_key) # β¦may be gone here β None
int(val.decode("utf-8")) # AttributeError on None
If the Redis key expired between exists() and get(), .decode("utf-8") was called on None, raising AttributeError and crashing the instance. The fix (PR #37548) collapses the two calls into a single get() and guards with a None check, falling back to the in-memory self.max_active_requests when the key is absent. This is the canonical fix pattern for this class of Redis TOCTOU bug.
The current implementation (flush_cache lines 50β71) reflects the fixed, single-get() approach .
Known Limitation: Non-Atomic Check-Then-Set#
The enter() path is not atomic. Between the HLEN check and the HSET, a concurrent worker on another process can read the same count and both workers may proceed past the limit. Under high concurrency this allows brief over-admission (by at most N_workers - 1 requests). The current design accepts this as a trade-off: the 10-minute stale-request eviction in flush_cache bounds drift, and the periodic recalculate (_ACTIVE_REQUESTS_COUNT_FLUSH_INTERVAL = 5 min) keeps the hash authoritative .
An atomic alternative would be a Lua script running HLEN + HSET in a single Redis round-trip, or using MULTI/EXEC with WATCH. Neither is currently implemented in this module.
Tests#
Unit tests live at api/tests/unit_tests/core/app/features/rate_limiting/test_rate_limit.py, covering singleton behavior, cache flush, enter/exit logic, the RateLimitGenerator wrapper, and concurrent access. After the TOCTOU fix, the exists.return_value mock was removed from the test suite .