aboutsummaryrefslogtreecommitdiff
path: root/webmentions_ssg/tasks/sender.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--webmentions_ssg/tasks/sender.py115
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()