diff --git a/changelog.d/12587.misc b/changelog.d/12587.misc
new file mode 100644
index 000000000..d26e33230
--- /dev/null
+++ b/changelog.d/12587.misc
@@ -0,0 +1 @@
+Add `@cancellable` decorator, for use on endpoint methods that can be cancelled when clients disconnect.
diff --git a/docs/usage/administration/request_log.md b/docs/usage/administration/request_log.md
index 316304c73..adb5f4f5f 100644
--- a/docs/usage/administration/request_log.md
+++ b/docs/usage/administration/request_log.md
@@ -28,7 +28,7 @@ See the following for how to decode the dense data available from the default lo
| NNNN | Total time waiting for response to DB queries across all parallel DB work from this request |
| OOOO | Count of DB transactions performed |
| PPPP | Response body size |
-| QQQQ | Response status code (prefixed with ! if the socket was closed before the response was generated) |
+| QQQQ | Response status code
Suffixed with `!` if the socket was closed before the response was generated.
A `499!` status code indicates that Synapse also cancelled request processing after the socket was closed.
|
| RRRR | Request |
| SSSS | User-agent |
| TTTT | Events fetched from DB to service this request (note that this does not include events fetched from the cache) |
diff --git a/synapse/http/server.py b/synapse/http/server.py
index 1cf49830e..657bffcdd 100644
--- a/synapse/http/server.py
+++ b/synapse/http/server.py
@@ -43,6 +43,7 @@ from typing_extensions import Protocol
from zope.interface import implementer
from twisted.internet import defer, interfaces
+from twisted.internet.defer import CancelledError
from twisted.python import failure
from twisted.web import resource
from twisted.web.server import NOT_DONE_YET, Request
@@ -82,6 +83,14 @@ HTML_ERROR_TEMPLATE = """