Files
mediapreview/mediapreview/exceptions.py
T
LeoVasanko 4103b8928c onlyoffice: fixed container IP instead of published port; fetch-aware timeout
Docker silently discards published ports on internal networks, so the
isolated oonet setup left the container unreachable at localhost:8988.
Reach it at its fixed IP (172.30.0.2) on the bridge instead; the host
is the gateway, so this needs no port publishing at all. The oosetup
CLI drops its now-meaningless <port> argument.

Also: record whether OnlyOffice fetched the input file from the
temporary callback server and report it in the timeout error message
('input file never fetched' = network/callback failure, vs a stalled
conversion). Convert POST timeout is 7s (conversion runs inside the
request), result PNG download stays at 2s.
2026-08-13 06:51:31 +00:00

195 lines
6.8 KiB
Python

"""Structured preview exceptions.
Preview failures are represented by ``PreviewError`` and a small number of
subclasses, carrying their fields directly: ``short`` (concise, for UI with
limited space — the backend name is usually printed in front of it, so it is
left out), the exception message itself (for logs), ``backend``, and
optional subclass-specific metadata such as ``code`` or ``timeout_seconds``
for callers that wish to do their own processing.
The worker pool ships exceptions between processes with pickle (same
trust domain — the pool unpickles only data from its own workers), so any
exception arrives intact on the caller side, no per-class serialization
machinery needed. Because the initializers are keyword-heavy, pickling is
routed through ``__dict__`` via ``PreviewError.__reduce__``.
The hierarchy is intentionally small:
- ``OnlyOfficeError`` covers all OnlyOffice failures; optional fields
(``code``, ``status``, ``url``, ``snippet``) describe the specific failure.
- ``PreviewBackendError`` covers backend conversion failures (ffmpeg, vips,
pdf, etc.). Combined pipelines report the failing step in ``backend``
(e.g. "pdf" if pdf reading failed before vips was reached, "pdf+vips"
for a vips write failure).
- ``PreviewTimeoutError`` covers timeouts for any backend.
- ``PreviewCancelledError`` covers cancellations (e.g. pool shutdown).
BaseExceptions such as ``KeyboardInterrupt``, ``SystemExit`` and
``asyncio.CancelledError`` are never wrapped in these types.
"""
from __future__ import annotations
class PreviewError(Exception):
"""Base preview exception.
``short`` is concise text for UIs with limited space (the backend name
is usually printed in front of it); ``str(err)`` is the full
human-readable message; ``backend`` identifies the backend
(e.g. "onlyoffice", "ffmpeg").
"""
def __init__(
self,
message: str = "preview failed",
short: str = "error",
*,
backend: str | None = None,
):
super().__init__(message)
self.short = short
self.backend = backend
def __reduce__(self):
# Keyword-heavy initializers do not unpickle via args; pass the message
# positionally and restore the rest from __dict__.
return (type(self), (str(self),), self.__dict__)
class OnlyOfficeError(PreviewError):
"""OnlyOffice conversion failed. Specifics are in the extra fields."""
def __init__( # noqa: PLR0913 - metadata fields are independent
self,
message: str = "OnlyOffice conversion failed",
short: str = "error",
*,
code: str | None = None,
status: int | None = None,
url: str | None = None,
snippet: str | None = None,
backend: str | None = "onlyoffice",
):
super().__init__(message, short, backend=backend)
self.code = code
self.status = status
self.url = url
self.snippet = snippet
class PreviewBackendError(PreviewError):
"""Backend conversion failure (image/video/pdf/etc)."""
class PreviewTimeoutError(PreviewError):
"""Preview conversion exceeded its timeout for a given backend."""
def __init__(
self,
message: str = "preview timed out",
short: str = "timeout",
*,
timeout_seconds: float = 0.0,
backend: str | None = None,
fetched: bool | None = None,
):
super().__init__(message, short, backend=backend)
self.timeout_seconds = timeout_seconds
self.fetched = fetched
class PreviewCancelledError(PreviewError):
"""Preview was cancelled (e.g. pool shut down)."""
def __init__(
self,
message: str = "Preview cancelled (pool closed)",
short: str = "cancelled",
*,
reason: str = "pool closed",
backend: str | None = None,
):
super().__init__(message, short, backend=backend)
self.reason = reason
# ---------------------------------------------------------------------------
# Factory helpers
# ---------------------------------------------------------------------------
_OO_CODE_ERRORS = {
"-8": ("jwt error", "OnlyOffice JWT authentication failed"),
"-4": ("input error", "OnlyOffice input error"),
"-2": ("timeout error", "OnlyOffice conversion timed out"),
"-1": ("unknown error", "OnlyOffice conversion failed with unknown error"),
}
def onlyoffice_error_from_code(code: str | None = None) -> OnlyOfficeError:
"""Build an OnlyOfficeError from a conversion status code (e.g. "-8")."""
if code in _OO_CODE_ERRORS:
short, log = _OO_CODE_ERRORS[code]
elif code:
short, log = f"{code} error", f"OnlyOffice conversion failed: {code}"
else:
short, log = "unknown error", "OnlyOffice conversion failed with unknown error"
return OnlyOfficeError(log, short, code=code)
def onlyoffice_unavailable_error(url: str | None = None) -> OnlyOfficeError:
log = "OnlyOffice document server not reachable"
if url:
log = f"{log} at {url}"
return OnlyOfficeError(log, "unavailable", url=url)
def onlyoffice_http_error(status: int) -> OnlyOfficeError:
return OnlyOfficeError(f"OnlyOffice HTTP error: {status}", "http error", status=status)
def onlyoffice_no_fileurl_error(snippet: str | None = None) -> OnlyOfficeError:
log = "OnlyOffice response did not contain FileUrl"
if snippet:
log = f"{log}: {snippet}"
return OnlyOfficeError(log, "no-fileurl error", snippet=snippet)
def backend_error(backend: str, message: str) -> PreviewBackendError:
short = message.splitlines()[0]
# Many backend messages look like "source: summary: detail ...".
# Drop the source prefix and any trailing detail so the short label
# is usable in UIs with limited space.
if ": " in short:
short = short.split(": ", 1)[1]
if ": " in short:
short = short.split(": ", 1)[0]
short = short[:60]
return PreviewBackendError(
f"[{backend}] preview failed: {message}",
short,
backend=backend,
)
def preview_timeout_error(
backend: str, timeout_seconds: float, fetched: bool | None = None
) -> PreviewTimeoutError:
log = f"{backend.capitalize()} preview timed out after {timeout_seconds}s"
if fetched is not None:
# OnlyOffice: whether it ever downloaded the input file from our
# callback server distinguishes network/callback failures from a
# stalled conversion.
log += " (input file fetched)" if fetched else " (input file never fetched)"
return PreviewTimeoutError(
log,
"timeout",
backend=backend,
timeout_seconds=timeout_seconds,
fetched=fetched,
)
def preview_cancelled_error(reason: str = "pool closed") -> PreviewCancelledError:
return PreviewCancelledError(f"Preview cancelled ({reason})", "cancelled", reason=reason)