Skip to content

Commit 0779587

Browse files
authored
Lift pausing on ratelimited requests to http layer (#18595)
When a request gets ratelimited we (optionally) wait ~500ms before returning to mitigate clients that like to tightloop on request failures. However, this is currently implemented by pausing request processing when we check for ratelimits, which might be deep within request processing, and e.g. while locks are held. Instead, let's hoist the pause to the very top of the HTTP handler. Hopefully, this mitigates the issue where a user sending lots of events to a single room can see their requests time out due to the combination of the linearizer and the pausing of the request. Instead, they should see the requests 429 after ~500ms. The first commit is a refactor to pass the `Clock` to `AsyncResource`, the second commit is the behavioural change.
1 parent 0c7d991 commit 0779587

21 files changed

Lines changed: 49 additions & 34 deletions

changelog.d/18595.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Better handling of ratelimited requests.

synapse/api/errors.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,14 +527,19 @@ def error_dict(self, config: Optional["HomeServerConfig"]) -> "JsonDict":
527527

528528

529529
class LimitExceededError(SynapseError):
530-
"""A client has sent too many requests and is being throttled."""
530+
"""A client has sent too many requests and is being throttled.
531+
532+
Args:
533+
pause: Optional time in seconds to pause before responding to the client.
534+
"""
531535

532536
def __init__(
533537
self,
534538
limiter_name: str,
535539
code: int = 429,
536540
retry_after_ms: Optional[int] = None,
537541
errcode: str = Codes.LIMIT_EXCEEDED,
542+
pause: Optional[float] = None,
538543
):
539544
# Use HTTP header Retry-After to enable library-assisted retry handling.
540545
headers = (
@@ -545,6 +550,7 @@ def __init__(
545550
super().__init__(code, "Too Many Requests", errcode, headers=headers)
546551
self.retry_after_ms = retry_after_ms
547552
self.limiter_name = limiter_name
553+
self.pause = pause
548554

549555
def error_dict(self, config: Optional["HomeServerConfig"]) -> "JsonDict":
550556
return cs_error(self.msg, self.errcode, retry_after_ms=self.retry_after_ms)

synapse/api/ratelimiting.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,12 +338,10 @@ async def ratelimit(
338338
)
339339

340340
if not allowed:
341-
if pause:
342-
await self.clock.sleep(pause)
343-
344341
raise LimitExceededError(
345342
limiter_name=self._limiter_name,
346343
retry_after_ms=int(1000 * (time_allowed - time_now_s)),
344+
pause=pause,
347345
)
348346

349347

synapse/http/additional_resource.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def __init__(
5353
hs: homeserver
5454
handler: function to be called to handle the request.
5555
"""
56-
super().__init__()
56+
super().__init__(hs.get_clock())
5757
self._handler = handler
5858

5959
async def _async_render(self, request: Request) -> Optional[Tuple[int, Any]]:

synapse/http/proxy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ class ProxyResource(_AsyncResource):
106106
isLeaf = True
107107

108108
def __init__(self, reactor: ISynapseReactor, hs: "HomeServer"):
109-
super().__init__(True)
109+
super().__init__(hs.get_clock(), True)
110110

111111
self.reactor = reactor
112112
self.agent = hs.get_federation_http_client().agent

synapse/http/server.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,15 @@
6767
from synapse.api.errors import (
6868
CodeMessageException,
6969
Codes,
70+
LimitExceededError,
7071
RedirectException,
7172
SynapseError,
7273
UnrecognizedRequestError,
7374
)
7475
from synapse.config.homeserver import HomeServerConfig
7576
from synapse.logging.context import defer_to_thread, preserve_fn, run_in_background
7677
from synapse.logging.opentracing import active_span, start_active_span, trace_servlet
77-
from synapse.util import json_encoder
78+
from synapse.util import Clock, json_encoder
7879
from synapse.util.caches import intern_dict
7980
from synapse.util.cancellation import is_function_cancellable
8081
from synapse.util.iterutils import chunk_seq
@@ -308,9 +309,10 @@ class _AsyncResource(resource.Resource, metaclass=abc.ABCMeta):
308309
context from the request the servlet is handling.
309310
"""
310311

311-
def __init__(self, extract_context: bool = False):
312+
def __init__(self, clock: Clock, extract_context: bool = False):
312313
super().__init__()
313314

315+
self._clock = clock
314316
self._extract_context = extract_context
315317

316318
def render(self, request: "SynapseRequest") -> int:
@@ -329,7 +331,12 @@ async def _async_render_wrapper(self, request: "SynapseRequest") -> None:
329331
request.request_metrics.name = self.__class__.__name__
330332

331333
with trace_servlet(request, self._extract_context):
332-
callback_return = await self._async_render(request)
334+
try:
335+
callback_return = await self._async_render(request)
336+
except LimitExceededError as e:
337+
if e.pause:
338+
self._clock.sleep(e.pause)
339+
raise
333340

334341
if callback_return is not None:
335342
code, response = callback_return
@@ -393,8 +400,10 @@ class DirectServeJsonResource(_AsyncResource):
393400
formatting responses and errors as JSON.
394401
"""
395402

396-
def __init__(self, canonical_json: bool = False, extract_context: bool = False):
397-
super().__init__(extract_context)
403+
def __init__(
404+
self, clock: Clock, canonical_json: bool = False, extract_context: bool = False
405+
):
406+
super().__init__(clock, extract_context)
398407
self.canonical_json = canonical_json
399408

400409
def _send_response(
@@ -450,8 +459,8 @@ def __init__(
450459
canonical_json: bool = True,
451460
extract_context: bool = False,
452461
):
453-
super().__init__(canonical_json, extract_context)
454462
self.clock = hs.get_clock()
463+
super().__init__(self.clock, canonical_json, extract_context)
455464
# Map of path regex -> method -> callback.
456465
self._routes: Dict[Pattern[str], Dict[bytes, _PathEntry]] = {}
457466
self.hs = hs

synapse/rest/consent/consent_resource.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class ConsentResource(DirectServeHtmlResource):
8181
"""
8282

8383
def __init__(self, hs: "HomeServer"):
84-
super().__init__()
84+
super().__init__(hs.get_clock())
8585

8686
self.hs = hs
8787
self.store = hs.get_datastores().main

synapse/rest/synapse/client/federation_whitelist.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class FederationWhitelistResource(DirectServeJsonResource):
4444
PATH = "/_synapse/client/v1/config/federation_whitelist"
4545

4646
def __init__(self, hs: "HomeServer"):
47-
super().__init__()
47+
super().__init__(hs.get_clock())
4848

4949
self._federation_whitelist = hs.config.federation.federation_domain_whitelist
5050

synapse/rest/synapse/client/jwks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
class JwksResource(DirectServeJsonResource):
3535
def __init__(self, hs: "HomeServer"):
36-
super().__init__(extract_context=True)
36+
super().__init__(hs.get_clock(), extract_context=True)
3737

3838
# Parameters that are allowed to be exposed in the public key.
3939
# This is done manually, because authlib's private to public key conversion

synapse/rest/synapse/client/new_user_consent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class NewUserConsentResource(DirectServeHtmlResource):
4848
"""
4949

5050
def __init__(self, hs: "HomeServer"):
51-
super().__init__()
51+
super().__init__(hs.get_clock())
5252
self._sso_handler = hs.get_sso_handler()
5353
self._server_name = hs.hostname
5454
self._consent_version = hs.config.consent.user_consent_version

0 commit comments

Comments
 (0)