diff --git a/src/testcontainers/core/container.py b/src/testcontainers/core/container.py index 76fce9c14..b206e2367 100644 --- a/src/testcontainers/core/container.py +++ b/src/testcontainers/core/container.py @@ -475,16 +475,24 @@ def get_instance(cls) -> "Reaper": @classmethod def delete_instance(cls) -> None: - if Reaper._socket is not None: - Reaper._socket.close() + container = Reaper._container + try: + if Reaper._socket is not None: + Reaper._socket.close() + + if container is not None and container._container is not None: + try: + container.stop() + except docker.errors.NotFound: + pass + except APIError as e: + # Ryuk runs with auto_remove, so if it died Docker may already be removing it (409). + if e.status_code != 409: + raise + finally: + # Reset whatever stop() did, so get_instance() creates a new reaper instead of returning a dead one. Reaper._socket = None - - if Reaper._container is not None and Reaper._container._container is not None: - with contextlib.suppress(docker.errors.NotFound): - Reaper._container.stop() Reaper._container = None - - if Reaper._instance is not None: Reaper._instance = None @classmethod diff --git a/tests/core/test_ryuk.py b/tests/core/test_ryuk.py index 81bb736ca..5d8a685d4 100644 --- a/tests/core/test_ryuk.py +++ b/tests/core/test_ryuk.py @@ -2,7 +2,7 @@ import pytest from docker import DockerClient -from docker.errors import NotFound +from docker.errors import APIError, NotFound from testcontainers.core.config import testcontainers_config from testcontainers.core.container import DockerContainer, Reaper @@ -95,3 +95,61 @@ def test_ryuk_is_reused_in_same_process(): with DockerContainer("hello-world") as container: wait_for_logs(container, "Hello from Docker!") assert reaper_instance is Reaper._instance + + +class _FakeResponse: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.reason = "Conflict" if status_code == 409 else "Server Error" + self.url = "http+docker://localhost/containers/ryuk" + + +class _DeadReaperContainer: + """Stands in for a ryuk container that died and that Docker is already auto-removing.""" + + def __init__(self, status_code: int) -> None: + self._container = object() + self._status_code = status_code + + def stop(self) -> None: + raise APIError("removal of container is already in progress", response=_FakeResponse(self._status_code)) + + +class _FakeSocket: + closed = False + + def close(self) -> None: + self.closed = True + + +def test_delete_instance_treats_removal_in_progress_as_gone(monkeypatch: pytest.MonkeyPatch): + # https://github.com/testcontainers/testcontainers-python/issues/1125 + dead = Reaper() + sock = _FakeSocket() + monkeypatch.setattr(Reaper, "_instance", dead) + monkeypatch.setattr(Reaper, "_container", _DeadReaperContainer(409)) + monkeypatch.setattr(Reaper, "_socket", sock) + + Reaper.delete_instance() + + assert sock.closed + assert Reaper._socket is None + assert Reaper._container is None + assert Reaper._instance is None + + fresh = Reaper() + monkeypatch.setattr(Reaper, "_create_instance", classmethod(lambda cls: fresh)) + assert Reaper.get_instance() is fresh + + +def test_delete_instance_resets_its_state_when_stop_fails(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(Reaper, "_instance", Reaper()) + monkeypatch.setattr(Reaper, "_container", _DeadReaperContainer(500)) + monkeypatch.setattr(Reaper, "_socket", _FakeSocket()) + + with pytest.raises(APIError): + Reaper.delete_instance() + + assert Reaper._socket is None + assert Reaper._container is None + assert Reaper._instance is None