diff --git a/frappe/concurrency_limiter.py b/frappe/concurrency_limiter.py index 0fe2e1a534..c75f0ad67f 100644 --- a/frappe/concurrency_limiter.py +++ b/frappe/concurrency_limiter.py @@ -56,7 +56,7 @@ def concurrent_limit(limit: int | None = None, wait_timeout: int | None = None): def wrapper(*args, **kwargs): # Skip concurrency limiting outside of HTTP requests (background jobs, # CLI commands, tests that call functions directly, etc.). - if not getattr(frappe.local, "request", None): + if getattr(frappe.local, "request", None) is None: return fn(*args, **kwargs) effective_limit = int(limit) if limit is not None else _default_limit() @@ -72,9 +72,11 @@ def concurrent_limit(limit: int | None = None, wait_timeout: int | None = None): if not acquired: from frappe.exceptions import ServiceUnavailableError - exc = ServiceUnavailableError(frappe._("Server is busy. Please try again in a few seconds.")) retry_after = max(1, int(effective_wait)) - frappe.local.response_headers.set("Retry-After", str(retry_after)) + if (headers := getattr(frappe.local, "response_headers", None)) is not None: + headers.set("Retry-After", str(retry_after)) + exc = ServiceUnavailableError(frappe._("Server is busy. Please try again in a few seconds.")) + exc.retry_after = retry_after raise exc try: diff --git a/frappe/tests/test_concurrency_limiter.py b/frappe/tests/test_concurrency_limiter.py index 67755c2db5..9f4818cd24 100644 --- a/frappe/tests/test_concurrency_limiter.py +++ b/frappe/tests/test_concurrency_limiter.py @@ -10,8 +10,12 @@ from frappe.exceptions import ServiceUnavailableError from frappe.tests import IntegrationTestCase +def _cache_name(fn): + return f"concurrency:{fn.__module__}.{fn.__qualname__}" + + def _cache_key(fn): - return frappe.cache.make_key(f"concurrency:{fn.__module__}.{fn.__qualname__}") + return frappe.cache.make_key(_cache_name(fn)) class TestConcurrentLimit(IntegrationTestCase): @@ -37,7 +41,7 @@ class TestConcurrentLimit(IntegrationTestCase): self.assertEqual(calls, [True]) # Counter must not have been touched - self.assertIsNone(frappe.cache.get(_cache_key(fn))) + self.assertFalse(frappe.cache.exists(_cache_key(fn))) def test_raises_immediately_when_limit_full(self): """ServiceUnavailableError is raised at once when wait_timeout=0 and the @@ -69,7 +73,7 @@ class TestConcurrentLimit(IntegrationTestCase): try: frappe.local.request = frappe._dict() fn() - self.assertEqual(int(frappe.cache.get(key) or 0), 0) + self.assertEqual(frappe.cache.incrby(_cache_key(fn), 0), 0) finally: del frappe.local.request frappe.cache.delete(key) @@ -86,7 +90,7 @@ class TestConcurrentLimit(IntegrationTestCase): try: frappe.local.request = frappe._dict() self.assertRaises(ValueError, fn) - self.assertEqual(int(frappe.cache.get(key) or 0), 0) + self.assertEqual(frappe.cache.incrby(_cache_key(fn), 0), 0) finally: del frappe.local.request frappe.cache.delete(key) @@ -149,7 +153,7 @@ class TestConcurrentLimit(IntegrationTestCase): _release(key) # correct release → 0 _release(key) # spurious extra release - counter = int(frappe.cache.get(key) or 0) + counter = frappe.cache.incrby(key, 0) frappe.cache.delete(key) self.assertGreaterEqual(counter, 0)