Skip to content

Commit 5cc6967

Browse files
authored
feat: add ReadTimeout handling for container start and stop processes in NauticalBackup (#668)
1 parent e7c5f26 commit 5cc6967

2 files changed

Lines changed: 88 additions & 0 deletions

File tree

app/backup.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import docker
1515
from docker.errors import APIError, ImageNotFound
1616
from docker.models.containers import Container
17+
from requests.exceptions import ReadTimeout
1718

1819
from app.api.config import Settings
1920
from app.db import DB
@@ -454,6 +455,9 @@ def _stop_container(self, c: Container, attempt=1) -> bool:
454455
self.log_this(f"Stopping {c.name}...", "INFO")
455456
try:
456457
c.stop(timeout=stop_timeout) # * Actually stop the container
458+
except ReadTimeout:
459+
self.log_this(f"Timed out waiting for {c.name} to stop. Checking container status...", "WARN")
460+
# Fall through to c.reload() — container may have stopped despite the timeout
457461
except APIError as e:
458462
self.log_this(f"Error stopping container {c.name}. Skipping backup for this container.", "ERROR")
459463
return False
@@ -476,6 +480,9 @@ def _start_container(self, c: Container, attempt=1) -> bool:
476480
try:
477481
self.log_this(f"Starting {c.name}...")
478482
c.start() # * Actually stop the container
483+
except ReadTimeout:
484+
self.log_this(f"Timed out waiting for {c.name} to start. Checking container status...", "WARN")
485+
# Fall through to c.reload() — container may have started despite the timeout
479486
except APIError as e:
480487
self.log_this(f"Error starting container {c.name}.", "ERROR")
481488
return False

pytest/test_backup.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from itertools import cycle
1010
import shlex
1111
from mock import call
12+
from requests.exceptions import ReadTimeout
1213

1314
from app.nautical_env import NauticalEnv
1415
from app.backup import NauticalBackup
@@ -2749,3 +2750,83 @@ def test_STOP_TIMEOUT_label(
27492750

27502751
# Ensure the set timeout is 20
27512752
mock_container1.stop.assert_called_once_with(timeout=20)
2753+
2754+
@mock.patch("subprocess.run")
2755+
@pytest.mark.parametrize("mock_container1", [{"name": "container1", "id": "123456789"}], indirect=True)
2756+
@pytest.mark.parametrize("mock_container2", [{"name": "container2", "id": "9876543210"}], indirect=True)
2757+
def test_stop_container_read_timeout_container_stopped(
2758+
self,
2759+
mock_subprocess_run: MagicMock,
2760+
mock_docker_client: MagicMock,
2761+
mock_container1: MagicMock,
2762+
mock_container2: MagicMock,
2763+
):
2764+
"""ReadTimeout on stop, but container actually stopped — backup should proceed normally"""
2765+
mock_subprocess_run.return_value.returncode = 0
2766+
mock_container1.stop.side_effect = ReadTimeout()
2767+
2768+
mock_docker_client.containers.list.return_value = [mock_container1, mock_container2]
2769+
nb = NauticalBackup(mock_docker_client)
2770+
nb.backup()
2771+
2772+
# Both containers should be backed up (container1 stopped despite timeout)
2773+
assert mock_subprocess_run.call_count == 2
2774+
mock_container1.start.assert_called()
2775+
mock_container2.stop.assert_called()
2776+
mock_container2.start.assert_called()
2777+
2778+
@mock.patch("subprocess.run")
2779+
@pytest.mark.parametrize(
2780+
"mock_container1",
2781+
[
2782+
{
2783+
"name": "container1",
2784+
"id": "123456789",
2785+
# Container never reaches exited state after the timeout
2786+
"status_side_effect": ["running", "running", "running", "running", "running"],
2787+
}
2788+
],
2789+
indirect=True,
2790+
)
2791+
@pytest.mark.parametrize("mock_container2", [{"name": "container2", "id": "9876543210"}], indirect=True)
2792+
def test_stop_container_read_timeout_container_not_stopped(
2793+
self,
2794+
mock_subprocess_run: MagicMock,
2795+
mock_docker_client: MagicMock,
2796+
mock_container1: MagicMock,
2797+
mock_container2: MagicMock,
2798+
):
2799+
"""ReadTimeout on stop and container never stopped — container1 fails, container2 still runs"""
2800+
mock_subprocess_run.return_value.returncode = 0
2801+
mock_container1.stop.side_effect = ReadTimeout()
2802+
2803+
mock_docker_client.containers.list.return_value = [mock_container1, mock_container2]
2804+
nb = NauticalBackup(mock_docker_client)
2805+
nb.backup()
2806+
2807+
# container1 was not stopped so it should not be backed up
2808+
# container2 should still be processed
2809+
mock_container2.stop.assert_called()
2810+
mock_container2.start.assert_called()
2811+
assert mock_subprocess_run.call_count == 1
2812+
2813+
@mock.patch("subprocess.run")
2814+
@pytest.mark.parametrize("mock_container1", [{"name": "container1", "id": "123456789"}], indirect=True)
2815+
def test_start_container_read_timeout(
2816+
self,
2817+
mock_subprocess_run: MagicMock,
2818+
mock_docker_client: MagicMock,
2819+
mock_container1: MagicMock,
2820+
):
2821+
"""ReadTimeout on start — backup should not crash and container failure should be recorded"""
2822+
mock_subprocess_run.return_value.returncode = 0
2823+
mock_container1.start.side_effect = ReadTimeout()
2824+
2825+
mock_docker_client.containers.list.return_value = [mock_container1]
2826+
nb = NauticalBackup(mock_docker_client)
2827+
nb.backup()
2828+
2829+
# Backup ran and rsync was called despite the start timeout
2830+
mock_subprocess_run.assert_called_once()
2831+
mock_container1.stop.assert_called()
2832+
mock_container1.start.assert_called()

0 commit comments

Comments
 (0)