From 7fd9f5d9fca896e7e855bf2b75204c98fb45c341 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 22 Jul 2026 16:59:15 +0100 Subject: [PATCH] Implement new `send_federation_http_request` module API This new API allows a module to send an HTTP request authenticated as the homeserver. This allows modules to make arbitrary requests to other homeservers on behalf of the homeserver. --- synapse/module_api/__init__.py | 114 +++++++++++++++++++++++++++- synapse/module_api/errors.py | 10 +++ synapse/module_api/module_errors.py | 58 ++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 synapse/module_api/module_errors.py diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py index 1131253028..29b5f2f8ef 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py @@ -43,7 +43,12 @@ from twisted.web.resource import Resource from synapse.api import errors from synapse.api.constants import ProfileFields -from synapse.api.errors import SynapseError +from synapse.api.errors import ( + FederationDeniedError, + HttpResponseException, + RequestSendFailed, + SynapseError, +) from synapse.api.presence import UserPresenceState from synapse.config import ConfigError from synapse.config.repository import MediaUploadLimit @@ -131,6 +136,12 @@ from synapse.module_api.callbacks.third_party_event_rules_callbacks import ( ON_THREEPID_BIND_CALLBACK, ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK, ) +from synapse.module_api.module_errors import ( + FederationHttpDeniedException, + FederationHttpNotRetryingDestinationException, + FederationHttpRequestSendFailedException, + FederationHttpResponseException, +) from synapse.push.httppusher import HttpPusher from synapse.rest.client.login import LoginResponse from synapse.storage import DataStore @@ -162,6 +173,7 @@ from synapse.util.caches.descriptors import CachedFunction, cached as _cached from synapse.util.clock import Clock from synapse.util.duration import Duration from synapse.util.frozenutils import freeze +from synapse.util.retryutils import NotRetryingDestination if TYPE_CHECKING: # Old versions don't have `LiteralString` @@ -644,6 +656,106 @@ class ModuleApi: """ return self._http_client + async def send_federation_http_request( + self, + method: str, + remote_server_name: str, + path: str, + query_parameters: Mapping[str, Any] | None = None, + body: JsonDict | None = None, + timeout: int | None = None, + ) -> JsonDict: + """Send an authenticated HTTP request to a remote homeserver. + + Synapse signs the request with the local homeserver's signing key and sends it + using the configured federation routing, TLS, IP filtering, and retry policy. + + Added in Synapse v1.158.0. + + Args: + method: The HTTP method to use. One of `GET`, `PUT`, `POST`, or + `DELETE`. Case-insensitive. + remote_server_name: The Matrix server name to send the request to. + Federation delegation is resolved automatically. + path: The absolute HTTP path for the request. + query_parameters: Query parameters to include in the request. + body: The JSON request body for `PUT` and `POST` requests. + timeout: Number of milliseconds to wait for response headers and the + response body. The configured federation timeout is used by default. + + Returns: + The decoded JSON object returned by the remote homeserver. + + Raises: + ValueError: If `method` is not supported. + FederationHttpResponseException: If the remote homeserver returns an + unsuccessful, non-retryable HTTP response. + FederationHttpNotRetryingDestinationException: If Synapse is backing off + requests to the remote homeserver. + FederationHttpDeniedException: If the remote homeserver is excluded by + Synapse's federation policy. + FederationHttpRequestSendFailedException: If the request could not be sent + or the response could not be decoded. + """ + method = method.upper() + federation_http_client = self._hs.get_federation_http_client() + + try: + if method == "GET": + return await federation_http_client.get_json( + destination=remote_server_name, + path=path, + args=query_parameters, + timeout=timeout, + ) + if method == "PUT": + return await federation_http_client.put_json( + destination=remote_server_name, + path=path, + args=query_parameters, + data=body, + timeout=timeout, + ) + if method == "POST": + return await federation_http_client.post_json( + destination=remote_server_name, + path=path, + args=query_parameters, + data=body, + timeout=timeout, + ) + if method == "DELETE": + return await federation_http_client.delete_json( + destination=remote_server_name, + path=path, + args=query_parameters, + timeout=timeout, + ) + + raise ValueError( + f"method must be one of GET, PUT, POST, or DELETE; received {method!r}" + ) + except HttpResponseException as e: + raise FederationHttpResponseException( + remote_server_name=remote_server_name, + status_code=e.code, + msg=e.msg, + response_body=e.response, + ) from e + except NotRetryingDestination as e: + raise FederationHttpNotRetryingDestinationException( + remote_server_name=remote_server_name + ) from e + except FederationDeniedError as e: + raise FederationHttpDeniedException( + remote_server_name=remote_server_name + ) from e + except RequestSendFailed as e: + raise FederationHttpRequestSendFailedException( + remote_server_name=remote_server_name, + can_retry=e.can_retry, + ) from e + @property def public_room_list_manager(self) -> "PublicRoomListManager": """Allows adding to, removing from and checking the status of rooms in the diff --git a/synapse/module_api/errors.py b/synapse/module_api/errors.py index f04b8fdb9d..18291e3af0 100644 --- a/synapse/module_api/errors.py +++ b/synapse/module_api/errors.py @@ -29,6 +29,12 @@ from synapse.api.errors import ( ) from synapse.config._base import ConfigError from synapse.handlers.push_rules import InvalidRuleException +from synapse.module_api.module_errors import ( + FederationHttpDeniedException, + FederationHttpNotRetryingDestinationException, + FederationHttpRequestSendFailedException, + FederationHttpResponseException, +) from synapse.storage.push_rule import RuleNotFoundException __all__ = [ @@ -37,6 +43,10 @@ __all__ = [ "RedirectException", "SynapseError", "ConfigError", + "FederationHttpDeniedException", + "FederationHttpNotRetryingDestinationException", + "FederationHttpRequestSendFailedException", + "FederationHttpResponseException", "InvalidRuleException", "RuleNotFoundException", ] diff --git a/synapse/module_api/module_errors.py b/synapse/module_api/module_errors.py new file mode 100644 index 0000000000..84144189db --- /dev/null +++ b/synapse/module_api/module_errors.py @@ -0,0 +1,58 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 New Vector, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# + +"""Exception types specific to the module API. + +The definitions cannot live in `synapse.module_api.errors` because +`synapse.module_api` historically re-exports `synapse.api.errors` under the name +`errors`. Importing the `synapse.module_api.errors` submodule while initializing the +parent package would replace that re-export and could break existing modules. + +The public `synapse.module_api.errors` module re-exports these exceptions, while the +parent package imports them directly from here to avoid that namespace collision. +""" + +import attr + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpResponseException(Exception): + """A remote homeserver returned an unsuccessful HTTP response.""" + + remote_server_name: str + status_code: int + msg: str + response_body: bytes + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpNotRetryingDestinationException(Exception): + """Synapse is backing off federation requests to the remote homeserver.""" + + remote_server_name: str + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpDeniedException(Exception): + """The remote homeserver is excluded by Synapse's federation policy.""" + + remote_server_name: str + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpRequestSendFailedException(Exception): + """Synapse could not send or decode a federation HTTP request.""" + + remote_server_name: str + can_retry: bool