mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
The morning routine is started by systemd right after boot and right after
resume, so its sync ran seconds before DHCP/DNS were up. Every such run failed
outright — and said this:
Workout sync push FAILED for 6 workout(s): network error reading
screen-locker-sync/devices — a 403 here means the token lacks
contents:write on kuhyx/syncs
Both halves of that were wrong. It was not a 403, and the token was fine: the
same six workouts pushed successfully by hand minutes later on the same token,
which reports push=true on the repo. The message cost a real investigation
chasing a permissions bug that did not exist.
- _sync_retry.with_sync_retry wraps the three sync call sites in a bounded
retry (4 attempts, 2s/4s/8s backoff — ~14s, enough for the network to come
up, short enough not to drag the routine). Verified offline in a network
namespace: three retries, then an honest give-up.
- Any GitHubSyncError retries, deliberately including RepoNotFoundError.
Classifying transient vs permanent would couple us to which exception
crdt_sync chains where; a few wasted seconds on a genuinely bad token is the
cheaper mistake.
- The push failure log now reports what actually happened. The contents:write
hint survives only for RepoNotFoundError, which is the one error that
actually implicates the repo or the token.
Retry at the application layer rather than ordering the unit after
network-online.target: the same race hits on resume, where boot ordering does
not apply, and these are user units that cannot depend on a system target.
The tests' autouse no-sleep fixture patches the module-local `sleep` name
rather than `_sync_retry.time.sleep` — the latter mutates the shared time
module object and silently disabled sleep for the whole suite, breaking an
unrelated temperature timeout test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
4.1 KiB
Python
99 lines
4.1 KiB
Python
"""Tests for the bounded sync retry that covers the boot/resume network race."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from crdt_sync import GitHubSyncError, RepoNotFoundError
|
|
import pytest
|
|
|
|
from screen_locker._sync_retry import with_sync_retry
|
|
|
|
|
|
class TestWithSyncRetry:
|
|
"""Tests for :func:`with_sync_retry`."""
|
|
|
|
def test_returns_result_without_retrying_on_success(self) -> None:
|
|
"""A working call runs exactly once and returns its value."""
|
|
operation = MagicMock(return_value="ok")
|
|
with patch("screen_locker._sync_retry.sleep") as sleep:
|
|
assert with_sync_retry(operation, description="do the thing") == "ok"
|
|
assert operation.call_count == 1
|
|
sleep.assert_not_called()
|
|
|
|
def test_recovers_when_a_later_attempt_succeeds(self) -> None:
|
|
"""The boot case: the network comes up mid-retry and the sync lands."""
|
|
operation = MagicMock(side_effect=[GitHubSyncError("network error"), "ok"])
|
|
with patch("screen_locker._sync_retry.sleep") as sleep:
|
|
assert with_sync_retry(operation, description="do the thing") == "ok"
|
|
assert operation.call_count == 2
|
|
sleep.assert_called_once_with(2.0)
|
|
|
|
def test_gives_up_after_the_attempt_budget(self) -> None:
|
|
"""A genuinely offline run stops after the configured attempts."""
|
|
operation = MagicMock(side_effect=GitHubSyncError("network error"))
|
|
with (
|
|
patch("screen_locker._sync_retry.sleep"),
|
|
pytest.raises(GitHubSyncError),
|
|
):
|
|
with_sync_retry(operation, description="do the thing", attempts=3)
|
|
assert operation.call_count == 3
|
|
|
|
def test_backoff_is_exponential(self) -> None:
|
|
"""Waits double each time, so ~14s covers DHCP/DNS without dragging."""
|
|
operation = MagicMock(side_effect=GitHubSyncError("network error"))
|
|
with (
|
|
patch("screen_locker._sync_retry.sleep") as sleep,
|
|
pytest.raises(GitHubSyncError),
|
|
):
|
|
with_sync_retry(
|
|
operation,
|
|
description="do the thing",
|
|
attempts=4,
|
|
delay_seconds=2.0,
|
|
)
|
|
assert [call.args[0] for call in sleep.call_args_list] == [2.0, 4.0, 8.0]
|
|
|
|
def test_retries_repo_not_found_too(self) -> None:
|
|
"""Permission-shaped errors retry as well — see the module docstring.
|
|
|
|
Classifying transient vs permanent would couple us to which exception
|
|
crdt_sync chains where; a few wasted seconds is the cheaper mistake.
|
|
"""
|
|
operation = MagicMock(side_effect=RepoNotFoundError("no access"))
|
|
with (
|
|
patch("screen_locker._sync_retry.sleep"),
|
|
pytest.raises(RepoNotFoundError),
|
|
):
|
|
with_sync_retry(operation, description="do the thing", attempts=2)
|
|
assert operation.call_count == 2
|
|
|
|
@pytest.mark.parametrize("attempts", [1, 0, -1])
|
|
def test_never_runs_fewer_than_once(self, attempts: int) -> None:
|
|
"""A nonsensical budget still runs the operation exactly once."""
|
|
operation = MagicMock(side_effect=GitHubSyncError("network error"))
|
|
with (
|
|
patch("screen_locker._sync_retry.sleep") as sleep,
|
|
pytest.raises(GitHubSyncError),
|
|
):
|
|
with_sync_retry(operation, description="do the thing", attempts=attempts)
|
|
assert operation.call_count == 1
|
|
sleep.assert_not_called()
|
|
|
|
def test_logs_each_retry_and_the_final_give_up(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Never fails silently: every swallowed attempt says why, at WARNING."""
|
|
operation = MagicMock(side_effect=GitHubSyncError("network error"))
|
|
with (
|
|
patch("screen_locker._sync_retry.sleep"),
|
|
caplog.at_level("WARNING"),
|
|
pytest.raises(GitHubSyncError),
|
|
):
|
|
with_sync_retry(operation, description="push things", attempts=3)
|
|
assert "attempt 1/3" in caplog.text
|
|
assert "attempt 2/3" in caplog.text
|
|
assert "after 3 attempt(s)" in caplog.text
|
|
assert "giving up" in caplog.text
|
|
assert "push things" in caplog.text
|