Files
stackpilot/backend/docker_client.py
T
menzeljandClaude Opus 5 f766ded528
CI / check (push) Successful in 13m35s
CI / build-and-push (push) Successful in 2m10s
Stop long prunes failing with a bare 500 (0.60.1)
A prune that deletes a lot of data (image prune -a in particular) easily
runs past docker-py's 60s read timeout. requests' ReadTimeout is not a
DockerException, so it slipped past safe_call and the UI only showed
"Request failed with status code 500".

Prunes now use a client with a 540s timeout (below nginx's 600s
proxy_read_timeout), and safe_call turns transport timeouts and
connection errors into DockerError, so they surface as a 502 with a
message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 09:39:47 +02:00

79 lines
2.6 KiB
Python

"""Singleton docker-py client wrapper.
All Docker access goes through this module. The Docker socket is NEVER exposed
to the frontend.
"""
from __future__ import annotations
import functools
from typing import Any
import docker
import requests
from docker.errors import APIError, DockerException, NotFound
from config import settings
class DockerError(Exception):
"""Structured error surfaced to API callers."""
def __init__(self, error: str, detail: str = ""):
self.error = error
self.detail = detail
super().__init__(f"{error}: {detail}" if detail else error)
# Read timeout for get_long_client(). docker-py's default of 60s is fine for
# interactive calls, but a prune that deletes gigabytes of volume data or image
# layers routinely takes longer. Kept below nginx's proxy_read_timeout (600s)
# so the caller still gets our error instead of a bare gateway timeout.
LONG_TIMEOUT_SECONDS = 540
def _connect(**kwargs) -> docker.DockerClient:
try:
base_url = f"unix://{settings.DOCKER_SOCKET}"
client = docker.DockerClient(base_url=base_url, **kwargs)
client.ping()
return client
except DockerException as exc: # pragma: no cover - environment dependent
raise DockerError("docker_unavailable", str(exc)) from exc
@functools.lru_cache(maxsize=1)
def get_client() -> docker.DockerClient:
return _connect()
@functools.lru_cache(maxsize=1)
def get_long_client() -> docker.DockerClient:
"""A client for calls that can legitimately run for minutes (prunes)."""
return _connect(timeout=LONG_TIMEOUT_SECONDS)
def reset_client() -> None:
get_client.cache_clear()
get_long_client.cache_clear()
def safe_call(fn, *args, **kwargs) -> Any:
"""Run a docker-py call, converting exceptions into DockerError."""
try:
return fn(*args, **kwargs)
except NotFound as exc:
raise DockerError("not_found", str(exc)) from exc
except APIError as exc:
raise DockerError("docker_api_error", str(exc)) from exc
except DockerException as exc:
raise DockerError("docker_error", str(exc)) from exc
# docker-py lets transport errors from requests through unwrapped; without
# these a slow daemon surfaced as a bare 500 with no message.
except requests.exceptions.Timeout as exc:
raise DockerError(
"docker_timeout",
"Docker did not answer in time; the operation may still be running on the host",
) from exc
except requests.exceptions.RequestException as exc:
raise DockerError("docker_unavailable", str(exc)) from exc