diff options
Diffstat (limited to '')
| -rw-r--r-- | webmentions_ssg/tasks/sender.py | 115 |
1 files changed, 101 insertions, 14 deletions
diff --git a/webmentions_ssg/tasks/sender.py b/webmentions_ssg/tasks/sender.py index fa9b624..629c538 100644 --- a/webmentions_ssg/tasks/sender.py +++ b/webmentions_ssg/tasks/sender.py @@ -1,6 +1,6 @@ import re import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from urllib.parse import urljoin import httpx @@ -31,12 +31,23 @@ class PermanentSenderError(SenderError): def temporary_http_status(status: int) -> bool: - """Return whether an HTTP status should be retried.""" + """ + Return whether an HTTP status indicates a temporary failure. + + :param status: HTTP response status code. + :return: Whether the request should be retried. + """ return status in {408, 425, 429} or 500 <= status <= 599 def ensure_public_request(request: httpx.Request) -> None: - """Prevent requests to non-public network addresses.""" + """ + Ensure that an HTTP request targets a public network address. + + :param request: HTTP request to validate. + :raises PermanentSenderError: If the request resolves to a non-public address. + :raises TemporarySenderError: If the request hostname cannot be resolved. + """ try: if not is_public_url(str(request.url)): raise PermanentSenderError("Request resolves to a non-public address") @@ -45,7 +56,15 @@ def ensure_public_request(request: httpx.Request) -> None: def resolve_endpoint(response: httpx.Response, href: str) -> str: - """Resolve and validate a discovered Webmention endpoint.""" + """ + Resolve and validate a discovered Webmention endpoint. + + :param response: Response from which the endpoint was discovered. + :param href: Endpoint reference to resolve. + :return: Absolute Webmention endpoint URL. + :raises PermanentSenderError: If the resolved endpoint is not an HTTP or + HTTPS URL. + """ endpoint = urljoin(str(response.url), href.strip()) if not is_http_url(endpoint): @@ -55,7 +74,12 @@ def resolve_endpoint(response: httpx.Response, href: str) -> str: def parse_link_value(value: str) -> tuple[str, set[str]] | None: - """Parse a Link header value into its target and relations.""" + """ + Parse a Link header value into its target and relations. + + :param value: Link header value to parse. + :return: Link target and relation names, or ``None`` if the value is invalid. + """ value = value.strip() if not value.startswith("<"): @@ -80,7 +104,13 @@ def parse_link_value(value: str) -> tuple[str, set[str]] | None: def endpoint_from_headers(response: httpx.Response) -> str | None: - """Return the first Webmention endpoint advertised by HTTP Link.""" + """ + Find a Webmention endpoint in the response Link headers. + + :param response: HTTP response whose headers should be inspected. + :return: First advertised Webmention endpoint, or ``None`` if none is found. + :raises PermanentSenderError: If a discovered endpoint is invalid. + """ for header in response.headers.get_list("Link"): for value in LINK_SPLIT.split(header): if (link := parse_link_value(value)) is None: @@ -95,7 +125,16 @@ def endpoint_from_headers(response: httpx.Response) -> str | None: def endpoint_from_html(response: httpx.Response, body: bytes) -> str | None: - """Return the first HTML Webmention endpoint in document order.""" + """ + Find a Webmention endpoint in an HTML document. + + ``link`` and ``a`` elements are inspected in document order. + + :param response: HTTP response from which the document was retrieved. + :param body: HTML document body. + :return: First advertised Webmention endpoint, or ``None`` if none is found. + :raises PermanentSenderError: If a discovered endpoint is invalid. + """ document = BeautifulSoup(body, "html5lib") for element in document.find_all(["link", "a"], href=True): @@ -124,7 +163,14 @@ def endpoint_from_html(response: httpx.Response, body: bytes) -> str | None: def read_target_body(response: httpx.Response) -> bytes: - """Read a target document up to the configured size limit.""" + """ + Read a target document up to the configured size limit. + + :param response: Streaming HTTP response to read. + :return: Response body. + :raises PermanentSenderError: If the document exceeds the configured maximum + size. + """ max_bytes = current_app.config.get("WEBMENTIONS_SSG_MAX_TARGET_BYTES", 1_000_000) if (content_length := response.headers.get("Content-Length")) is not None: @@ -146,7 +192,20 @@ def read_target_body(response: httpx.Response) -> bytes: def discover_webmention_endpoint(client: httpx.Client, target: str) -> str | None: - """Discover the Webmention endpoint advertised by a target.""" + """ + Discover the Webmention endpoint advertised by a target URL. + + Endpoint discovery first checks the response to a HEAD request and then falls + back to a GET request, inspecting both HTTP Link headers and supported HTML + documents. + + :param client: HTTP client to use for discovery requests. + :param target: Target URL whose Webmention endpoint should be discovered. + :return: Discovered Webmention endpoint, or ``None`` if none is advertised. + :raises TemporarySenderError: If the target returns a temporary HTTP failure. + :raises PermanentSenderError: If the target returns a permanent HTTP failure + or advertises an invalid endpoint. + """ head_response = client.head(target, headers=DISCOVERY_HEADERS) if endpoint := endpoint_from_headers(head_response): @@ -175,7 +234,15 @@ def discover_webmention_endpoint(client: httpx.Client, target: str) -> str | Non def post_webmention( client: httpx.Client, *, endpoint: str, source: str, target: str ) -> tuple[int, str | None]: - """POST a Webmention and return its status code and status URL.""" + """ + Send a Webmention to a discovered endpoint. + + :param client: HTTP client to use for the request. + :param endpoint: Webmention endpoint URL. + :param source: Source URL of the Webmention. + :param target: Target URL of the Webmention. + :return: HTTP response status code and optional status URL. + """ with client.stream( "POST", endpoint, data={"source": source, "target": target} ) as response: @@ -190,7 +257,16 @@ def post_webmention( def attempt_is_current(webmention: SentWebmention, revision: int) -> bool: - """Return whether an attempt still represents the desired revision.""" + """ + Check whether a send attempt still represents the desired revision. + + The Webmention is refreshed from the database before comparing its desired + and processed revisions. + + :param webmention: Sent Webmention being processed. + :param revision: Source revision represented by the current attempt. + :return: Whether the attempt is still current and requires processing. + """ db.session.refresh(webmention) return webmention.desired_revision == revision and ( @@ -201,7 +277,18 @@ def attempt_is_current(webmention: SentWebmention, revision: int) -> bool: @huey.task(retries=2, retry_delay=50) def send_webmention(webmention_uuid: uuid.UUID) -> None: - """Discover a receiver endpoint and send one Webmention.""" + """ + Discover a receiver endpoint and send a Webmention. + + The result of the attempt is stored on the corresponding sent Webmention. + Temporary failures are re-raised so Huey can retry them, while permanent + failures mark the current revision as processed. + + :param webmention_uuid: Identifier of the sent Webmention to process. + :raises TemporarySenderError: If endpoint discovery or delivery fails for a + potentially temporary reason. + :raises httpx.RequestError: If an HTTP request fails. + """ webmention = db.session.get(SentWebmention, webmention_uuid) if webmention is None: @@ -220,7 +307,7 @@ def send_webmention(webmention_uuid: uuid.UUID) -> None: source = webmention.source.url target = webmention.target - webmention.last_attempted_at = datetime.now(timezone.utc) + webmention.last_attempted_at = datetime.now(UTC) webmention.endpoint = None webmention.response_status = None webmention.status_url = None @@ -310,6 +397,6 @@ def send_webmention(webmention_uuid: uuid.UUID) -> None: webmention.response_status = response_status webmention.status_url = status_url - webmention.last_sent_at = datetime.now(timezone.utc) + webmention.last_sent_at = datetime.now(UTC) db.session.commit() |
