Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ source search or payload download. Live acceptance probes must remain
metadata-only, use credentials supplied at runtime, and never persist signed
URLs or account data.

LibGen's authenticated `/v1/health` endpoint checks its five known mirrors
concurrently, once each, with a five-second probe timeout and a one-second
session-cleanup timeout. Slow mirrors report `unavailable`; process health stays
separate from source availability. Cancelling the request cancels and cleans up
the outstanding probes. Search and download-resolution timeouts are unchanged.

Pull requests run four stable aggregate checks: `CI Required`,
`Security Required`, `Workflow Hygiene Required`, and
`Container Security Required`. They run on GitHub-hosted runners with read-only
Expand Down Expand Up @@ -249,8 +255,8 @@ either registry; both names resolve to the same signed digest.
| Provider | GHCR | Docker Hub |
| --- | --- | --- |
| GetComics | `ghcr.io/pullboxapp/pullbox-provider-getcomics:1.0.3` | `docker.io/pullbox/pullbox-provider-getcomics:1.0.3` |
| Anna's Archive | `ghcr.io/pullboxapp/pullbox-provider-annas-archive:1.0.3` | `docker.io/pullbox/pullbox-provider-annas-archive:1.0.3` |
| LibGen | `ghcr.io/pullboxapp/pullbox-provider-libgen:1.0.1` | `docker.io/pullbox/pullbox-provider-libgen:1.0.1` |
| Anna's Archive | `ghcr.io/pullboxapp/pullbox-provider-annas-archive:1.0.4` | `docker.io/pullbox/pullbox-provider-annas-archive:1.0.4` |
| LibGen | `ghcr.io/pullboxapp/pullbox-provider-libgen:1.0.2` | `docker.io/pullbox/pullbox-provider-libgen:1.0.2` |

Pin a numbered version or the immutable digest in production. `latest` tracks
only the newest stable provider release; prerelease and manual `edge` builds do
Expand Down
4 changes: 2 additions & 2 deletions providers/annas_archive/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ build-backend = "hatchling.build"

[project]
name = "pullbox-provider-annas-archive"
version = "1.0.3"
version = "1.0.4"
description = "Optional Anna's Archive discovery provider for Pullbox"
requires-python = ">=3.12"
license = "GPL-3.0-or-later"
dependencies = [
"pullbox-direct-provider-contract==1.0.0",
"pullbox-provider-libgen==1.0.1",
"pullbox-provider-libgen==1.0.2",
"uvicorn>=0.34,<1",
]

Expand Down
2 changes: 1 addition & 1 deletion providers/libgen/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pullbox-provider-libgen"
version = "1.0.1"
version = "1.0.2"
description = "Optional LibGen discovery provider for Pullbox"
requires-python = ">=3.12"
license = "GPL-3.0-or-later"
Expand Down
87 changes: 68 additions & 19 deletions providers/libgen/src/pullbox_provider_libgen/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import socket
import time
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import suppress
from typing import Protocol
from urllib.parse import urlencode, urlsplit, urlunsplit

Expand Down Expand Up @@ -58,6 +59,9 @@
"home.arpa",
)
_MAX_METADATA_BYTES = 512 * 1024
_SOURCE_HEALTH_TIMEOUT_SECONDS = 5.0
_SOURCE_HEALTH_CLOSE_TIMEOUT_SECONDS = 1.0
_DETACHED_HEALTH_CLOSE_TASKS: set[asyncio.Task[None]] = set()
_LOGGER = structlog.get_logger(__name__)

SourceResolver = Callable[[str, int], Awaitable[Sequence[str]]]
Expand All @@ -79,6 +83,41 @@ class LibGenSourceOriginError(ValueError):
"""The configured LibGen source origin is unsafe or unavailable."""


def _consume_health_close_result(task: asyncio.Task[None]) -> None:
"""Retrieve a detached close task's result without delaying health responses."""
_DETACHED_HEALTH_CLOSE_TASKS.discard(task)
with suppress(asyncio.CancelledError, Exception):
task.result()


def _cancel_health_close_task(task: asyncio.Task[None]) -> None:
"""Cancel cleanup without trusting the transport to honor cancellation."""
_DETACHED_HEALTH_CLOSE_TASKS.add(task)
task.cancel()
task.add_done_callback(_consume_health_close_result)


async def _close_health_session(session: SourceSession) -> bool:
"""Close one health session within its budget, even if the request is cancelled."""
close_task = asyncio.create_task(session.aclose())
deadline = asyncio.get_running_loop().time() + _SOURCE_HEALTH_CLOSE_TIMEOUT_SECONDS
try:
try:
async with asyncio.timeout_at(deadline):
await asyncio.shield(close_task)
except asyncio.CancelledError:
try:
async with asyncio.timeout_at(deadline):
await asyncio.shield(close_task)
except TimeoutError:
_cancel_health_close_task(close_task)
raise
except TimeoutError:
_cancel_health_close_task(close_task)
return False
return True


async def validate_source_origin(
raw_url: str,
*,
Expand Down Expand Up @@ -237,27 +276,37 @@ def __init__(
)

async def source_health(self) -> dict[str, ProviderStatus]:
health: dict[str, ProviderStatus] = {}
for origin in KNOWN_SOURCE_URLS:
session = self._session_factory(origin, None)
try:
# Probe each fixed mirror once; a slow mirror must not delay the others.
async with asyncio.TaskGroup() as group:
probes = {
origin: group.create_task(self._source_health_probe(origin))
for origin in KNOWN_SOURCE_URLS
}
return {
urlsplit(origin).hostname or origin: task.result() for origin, task in probes.items()
}

async def _source_health_probe(self, origin: str) -> ProviderStatus:
session = self._session_factory(origin, None)
try:
async with asyncio.timeout(_SOURCE_HEALTH_TIMEOUT_SECONDS):
await session.fetch_text(f"{origin}/index.php")
except BrowserChallengeRequiredError:
status = ProviderStatus.CHALLENGE_REQUIRED
except LibGenSourceError as exc:
status = (
ProviderStatus.RATE_LIMITED
if exc.code == "source_rate_limited"
else ProviderStatus.UNAVAILABLE
)
except ProviderResolverError:
except BrowserChallengeRequiredError:
status = ProviderStatus.CHALLENGE_REQUIRED
except LibGenSourceError as exc:
status = (
ProviderStatus.RATE_LIMITED
if exc.code == "source_rate_limited"
else ProviderStatus.UNAVAILABLE
)
except (ProviderResolverError, TimeoutError):
status = ProviderStatus.UNAVAILABLE
else:
status = ProviderStatus.HEALTHY
finally:
if not await _close_health_session(session):
status = ProviderStatus.UNAVAILABLE
else:
status = ProviderStatus.HEALTHY
finally:
await session.aclose()
health[urlsplit(origin).hostname or origin] = status
return health
return status

async def search(
self,
Expand Down
Loading
Loading