Unreachable mail servers: send retries in Scheduled, real action retries, account health

Sending: a send the server could not take (unreachable, timeout, 4xx) is no
longer failed at once. It waits in Scheduled ("retrying") and is tried again
after 1, 2, 5, 10, 30 min, then hourly, for 24 hours — on the server, so it
works with the browser closed. A permanent refusal (5xx, wrong password) is
"failed" at once, with the server's reason. GET /outbox lists both;
POST /outbox/{id}/retry (Retry now) and /cancel (Edit, Delete) act on them,
and they are left out of Drafts. Mail delivered but not filed in Sent now
counts as sent, and a conditional claim keeps two jobs from sending one
message twice. The app says when a send failed and when it finally went.

Actions: arq retries a job only on Retry, never on a plain exception, so the
"retry x5, then revert" for flags and moves never happened. Flag, move,
archive, restore, delete and empty-folder jobs now raise Retry while the
server is unreachable (~24 hours of growing gaps), skip a change a later one
replaced, stop at the first connection failure instead of 20 s per message,
and delete/empty keep the rows until the server has the messages gone.

Account health: 3 failed connections in a row mark an account unreachable
(amber in the sidebar and Settings, cleared on the next success); 3 refused
sign-ins stop its syncing as auth_failed until the password is entered
again, so the provider does not lock the account. Sync loops back off 30 s
doubling to 10 min and log one line per expected failure; a refused
connection fails at once with its reason (the connect task is kept), and a
silently dead IDLE connection is noticed within ~11 min instead of ~29.

Also: the empty reading pane is clipped to its width when dragged very
narrow; tests no longer leave live accounts and outbox rows in the database.

Migration 0021: outbox attempts / next_attempt_at / retry_until, accounts
sync_error / sync_error_since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 22:08:42 +02:00
co-authored by Claude Opus 5.5
parent e1bda37188
commit 609e7c58f9
31 changed files with 1375 additions and 151 deletions
+106 -50
View File
@@ -1,9 +1,11 @@
"""Outbound user actions: flag changes and moves (SPEC §6.3).
The API writes the change to the DB first, then enqueues a job here to push it to
the mail server. On provider failure the job retries (arq, ×5 exponential) and, on
final failure, reverts the DB change and emits ``flags.changed`` with
``reverted=true`` so the UI can roll back.
the mail server. While the server cannot be reached the job is retried (arq
``Retry``) with growing gaps for about 24 hours (``ACTION_MAX_TRIES``); a flag
change that still cannot be pushed is then reverted in the DB and announced with
``flags.changed`` / ``reverted=true`` so the UI rolls back. A plain exception does
NOT make arq retry — only ``Retry`` does.
"""
from __future__ import annotations
@@ -15,6 +17,7 @@ from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
from typing import Any
from arq import Retry
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,6 +25,7 @@ from app.config import get_settings
from app.db import session_scope
from app.events import get_event_bus
from app.models import Account, Draft, Mailbox, Message, Thread
from app.sync.health import is_temporary
from app.sync.provider_factory import build_provider
from app.threading_.threader import denormalize_thread
@@ -30,7 +34,26 @@ logger = logging.getLogger(__name__)
# User-settable IMAP flags (SPEC §8.2 is:read/starred/answered). \Deleted/\Draft
# are managed by move/expunge and drafts, not this surface.
ALLOWED_FLAGS = frozenset({"\\Seen", "\\Flagged", "\\Answered"})
_MAX_TRIES = 5
# Gaps between tries while the server is unreachable: ~24 hours in all.
_RETRY_DEFERS_S = (30, 60, 120, 300, 600, 1800, 3600, 7200, 14400, 28800, 28800)
ACTION_MAX_TRIES = len(_RETRY_DEFERS_S) + 1
def _retry_later(ctx: dict[str, Any], exc: BaseException) -> Retry | None:
"""A ``Retry`` for a temporary failure while tries remain, else None (give up)."""
if not is_temporary(exc):
return None
attempt = int(ctx.get("job_try", 1))
if attempt >= ACTION_MAX_TRIES:
return None
logger.info(
"server unreachable (try %d), retrying in %ds: %s",
attempt,
_RETRY_DEFERS_S[attempt - 1],
exc,
)
return Retry(defer=_RETRY_DEFERS_S[attempt - 1])
# Folder names to create when a target special-use folder is missing on the server.
_DEFAULT_FOLDER_NAME = {"archive": "Archive", "trash": "Trash", "junk": "Spam"}
@@ -71,6 +94,8 @@ async def push_flags_job(
message = await session.get(Message, mid)
if message is None:
return
if _superseded(message.flags, add, remove):
return # changed again since: that later change's own job pushes it
account = await session.get(Account, message.account_id)
mailbox = await session.get(Mailbox, message.mailbox_id)
if account is None or mailbox is None:
@@ -80,21 +105,29 @@ async def push_flags_job(
provider = build_provider(account)
try:
await provider.push_flags(path, uid, frozenset(add), frozenset(remove))
except Exception:
if ctx.get("job_try", 1) >= _MAX_TRIES:
await _revert_flags(mid, user_id, add, remove)
return
raise # arq retries with backoff
except Exception as exc:
retry = _retry_later(ctx, exc)
if retry is not None:
raise retry from exc
logger.warning("flag change for message %s could not be pushed: %s", mid, exc)
await _revert_flags(mid, user_id, add, remove)
finally:
await provider.close()
def _superseded(flags: list[str], add: list[str], remove: list[str]) -> bool:
"""The message's flags no longer show this change: a later one replaced it
(e.g. read, then unread again while the server was unreachable)."""
now = set(flags)
return not set(add) <= now or bool(set(remove) & now)
async def _revert_flags(
message_id: uuid.UUID, user_id: uuid.UUID, add: list[str], remove: list[str]
) -> None:
async with session_scope() as session:
message = await session.get(Message, message_id)
if message is None:
if message is None or _superseded(message.flags, add, remove):
return
# Undo the change: remove what we added, add back what we removed.
message.flags = sorted((set(message.flags) - set(add)) | set(remove))
@@ -138,11 +171,12 @@ async def move_job(
provider = build_provider(account)
try:
new_uid = await provider.move(uid, src_path, dst_path)
except Exception:
if ctx.get("job_try", 1) >= _MAX_TRIES:
logger.warning("move failed for message %s; leaving optimistic state", mid)
return
raise
except Exception as exc:
retry = _retry_later(ctx, exc)
if retry is not None:
raise retry from exc
logger.warning("move failed for message %s; leaving optimistic state: %s", mid, exc)
return
finally:
await provider.close()
@@ -193,28 +227,39 @@ async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special
# (message_id, account_id, target_path, new_uid) applied after the network moves.
moves: list[tuple[uuid.UUID, uuid.UUID, str, int]] = []
to_create: list[tuple[uuid.UUID, str, int]] = [] # account_id, path, uidvalidity
unreachable: BaseException | None = None # set: retry the moves that did not happen
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
if account_id in target_id_path:
target_id, target_path = target_id_path[account_id]
else:
target_id, target_path = None, _DEFAULT_FOLDER_NAME.get(special_use, special_use)
await provider.create_mailbox(target_path)
remote = await provider.list_mailboxes()
uidv = next((r.uidvalidity for r in remote if r.path == target_path), 0)
to_create.append((account_id, target_path, uidv))
for mid, m_account, m_mailbox, m_uid in snapshot:
if m_account != account_id or m_mailbox == target_id:
continue
src_path = path_by_id.get(m_mailbox, "INBOX")
try:
new_uid = await provider.move(m_uid, src_path, target_path)
except Exception:
logger.exception("move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, account_id, target_path, new_uid))
try:
if account_id in target_id_path:
target_id, target_path = target_id_path[account_id]
else:
target_id, target_path = (
None,
_DEFAULT_FOLDER_NAME.get(special_use, special_use),
)
await provider.create_mailbox(target_path)
remote = await provider.list_mailboxes()
uidv = next((r.uidvalidity for r in remote if r.path == target_path), 0)
to_create.append((account_id, target_path, uidv))
for mid, m_account, m_mailbox, m_uid in snapshot:
if m_account != account_id or m_mailbox == target_id:
continue
src_path = path_by_id.get(m_mailbox, "INBOX")
try:
new_uid = await provider.move(m_uid, src_path, target_path)
except Exception as exc:
if is_temporary(exc):
raise # the server is gone: stop this account, retry it later
logger.exception("move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, account_id, target_path, new_uid))
except Exception as exc:
if not is_temporary(exc):
raise
unreachable = exc
finally:
await provider.close()
@@ -246,6 +291,10 @@ async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
# What moved is recorded; the rest goes again once the server is back (the
# re-run skips messages already in the target folder).
if unreachable is not None and (retry := _retry_later(ctx, unreachable)) is not None:
raise retry from unreachable
async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
@@ -270,9 +319,7 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
inbox_by_account = {
mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"
}
inbox_by_account = {mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"}
# (message_id, account_id, source_path, target_id) — resolve the target now.
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
for m in messages:
@@ -287,6 +334,7 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
# (message_id, target_id, new_uid) applied after the network moves.
moves: list[tuple[uuid.UUID, uuid.UUID, int]] = []
unreachable: BaseException | None = None
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
@@ -296,7 +344,10 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
target_path = path_by_id[target_id]
try:
new_uid = await provider.move(uid, src_path, target_path)
except Exception:
except Exception as exc:
if is_temporary(exc):
unreachable = exc # the server is gone: stop this account
break
logger.exception("restore move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, target_id, new_uid))
@@ -314,6 +365,8 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
if unreachable is not None and (retry := _retry_later(ctx, unreachable)) is not None:
raise retry from unreachable
async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
@@ -352,8 +405,12 @@ async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
continue
try:
await provider.delete_messages(path, uids)
except Exception:
logger.exception("expunge failed for %s in %s", uids, path)
except Exception as exc:
# Keep the rows while the server is away: deleted here but
# not there, the next sync would bring the messages back.
if (retry := _retry_later(ctx, exc)) is not None:
raise retry from exc
logger.warning("expunge failed for %s in %s: %s", uids, path, exc)
finally:
await provider.close()
@@ -389,9 +446,7 @@ async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str)
async with session_scope() as session:
mailboxes = list(
await session.scalars(
select(Mailbox).where(
Mailbox.user_id == uid, Mailbox.special_use == special_use
)
select(Mailbox).where(Mailbox.user_id == uid, Mailbox.special_use == special_use)
)
)
if not mailboxes:
@@ -423,8 +478,11 @@ async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str)
continue
try:
await provider.delete_messages(path, uids)
except Exception:
logger.exception("empty folder: expunge failed for %s in %s", uids, path)
except Exception as exc:
# As for a thread: keep the rows until the server has them gone.
if (retry := _retry_later(ctx, exc)) is not None:
raise retry from exc
logger.warning("empty folder: expunge failed for %s in %s: %s", uids, path, exc)
finally:
await provider.close()
@@ -520,9 +578,9 @@ async def autopurge_trash_spam(ctx: dict[str, Any]) -> None:
user_ids = {r.user_id for r in stale}
async with session_scope() as session:
await session.execute(
update(Draft).where(Draft.reply_to_message_id.in_(msg_ids)).values(
reply_to_message_id=None
)
update(Draft)
.where(Draft.reply_to_message_id.in_(msg_ids))
.values(reply_to_message_id=None)
)
await session.execute(delete(Message).where(Message.id.in_(msg_ids)))
await session.flush()
@@ -539,6 +597,4 @@ async def autopurge_trash_spam(ctx: dict[str, Any]) -> None:
bus = get_event_bus()
for user_id in user_ids:
await bus.publish("message.deleted", {"user_id": str(user_id)})
logger.info(
"autopurge: removed %d Trash/Spam message(s) older than %dd", len(msg_ids), days
)
logger.info("autopurge: removed %d Trash/Spam message(s) older than %dd", len(msg_ids), days)
+39 -10
View File
@@ -18,10 +18,11 @@ from app.config import get_settings
from app.events import get_event_bus
from app.indexer.indexer import PersistResult, persist_message
from app.models import Account, Draft, Mailbox, Message
from app.providers.base import MailboxDelta, ProviderNotSupported
from app.providers.base import MailboxDelta, ProviderAuthError, ProviderNotSupported
from app.providers.imap_smtp import ImapSmtpProvider
from app.search.factory import get_search_backend
from app.sync.folder_names import infer_special_use
from app.sync.health import AccountHealth, is_temporary
from app.sync.provider_factory import build_provider
from app.threading_.threader import denormalize_thread
@@ -58,6 +59,12 @@ async def _set_state(account_id: uuid.UUID, state: str, detail: str = "") -> Non
await session.execute(
update(Account).where(Account.id == account_id).values(state=state, state_detail=detail)
)
if state == "live": # (re)connected and synced: no sync problem any more
await session.execute(
update(Account)
.where(Account.id == account_id)
.values(sync_error=None, sync_error_since=None)
)
# Keep the sync:live registry in step with the account's state so the sync
# coordinator claims it once live and drops it otherwise (refactor §2).
from app.redis_client import get_redis
@@ -504,7 +511,10 @@ async def _reconcile_drafts(
async def live_loop(
account_id: uuid.UUID, mailbox_path: str = "INBOX", stop: asyncio.Event | None = None
account_id: uuid.UUID,
mailbox_path: str = "INBOX",
stop: asyncio.Event | None = None,
health: AccountHealth | None = None,
) -> None:
"""Maintain a live IDLE/poll loop for one mailbox (SPEC §6.3)."""
from app.db import session_scope
@@ -518,6 +528,9 @@ async def live_loop(
provider = build_provider(account_obj)
try:
await provider.verify() # signed in: the account is reachable
if health is not None:
await health.ok()
while not stop.is_set():
try:
await provider.idle(mailbox_path)
@@ -544,7 +557,9 @@ async def live_loop(
await provider.close()
async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> None:
async def poll_folders_once(
account_id: uuid.UUID, *, include_inbox: bool, health: AccountHealth | None = None
) -> None:
"""One CONDSTORE poll pass over an account's special-use folders.
``include_inbox`` also polls INBOX (used by the manual Refresh); the periodic
@@ -586,14 +601,22 @@ async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> No
highestmodseq=mailbox_obj.highestmodseq,
)
await apply_delta(provider, account_obj, mailbox_obj, delta)
except Exception:
except Exception as exc:
# Server gone, or the sign-in refused: the other folders would only
# fail the same way (20 s each). End the pass; the caller decides.
if is_temporary(exc) or isinstance(exc, ProviderAuthError):
raise
logger.exception("poll failed: account=%s mailbox=%s", account_id, mailbox_obj.path)
if health is not None:
await health.ok()
finally:
await provider.close()
async def _poll_secondary_once(account_id: uuid.UUID) -> None:
await poll_folders_once(account_id, include_inbox=False)
async def _poll_secondary_once(
account_id: uuid.UUID, health: AccountHealth | None = None
) -> None:
await poll_folders_once(account_id, include_inbox=False, health=health)
async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None:
@@ -602,7 +625,10 @@ async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None:
async def secondary_poll_loop(
account_id: uuid.UUID, stop: asyncio.Event | None = None, interval: float | None = None
account_id: uuid.UUID,
stop: asyncio.Event | None = None,
interval: float | None = None,
health: AccountHealth | None = None,
) -> None:
"""Periodically CONDSTORE-poll non-INBOX folders (Sent/Drafts/Archive/Trash/Junk).
@@ -624,11 +650,14 @@ async def secondary_poll_loop(
await asyncio.wait_for(stop.wait(), timeout=wait)
if stop.is_set():
break
await _poll_secondary_once(account_id)
await _poll_secondary_once(account_id, health)
async def poll_all_loop(
account_id: uuid.UUID, interval: float, stop: asyncio.Event | None = None
account_id: uuid.UUID,
interval: float,
stop: asyncio.Event | None = None,
health: AccountHealth | None = None,
) -> None:
"""Transient CONDSTORE poll of INBOX + special folders every ``interval`` seconds.
@@ -646,4 +675,4 @@ async def poll_all_loop(
await asyncio.wait_for(stop.wait(), timeout=wait)
if stop.is_set():
break
await poll_folders_once(account_id, include_inbox=True)
await poll_folders_once(account_id, include_inbox=True, health=health)
+109
View File
@@ -0,0 +1,109 @@
"""An account's connection health.
Telling "the mail server is not there right now" from everything else.
``is_temporary`` decides whether a failure is worth another try later: the
server could not be reached, timed out, dropped the connection, or answered
"not now" (SMTP 4xx). Sends, user actions and the sync loops all retry on it,
and only on it — a refused password or a permanent 5xx is not retried.
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from aioimaplib import aioimaplib
from sqlalchemy import update
from app.providers.base import ProviderAuthError, ProviderConnectError
logger = logging.getLogger(__name__)
def is_temporary(exc: BaseException) -> bool:
if isinstance(exc, (ProviderConnectError, TimeoutError, OSError)):
return True
# aioimaplib's own: a command that timed out, or a reply cut off.
if isinstance(exc, (aioimaplib.CommandTimeout, aioimaplib.IncompleteRead)):
return True
# After the connection drops, aioimaplib refuses every further command as
# "illegal in state LOGOUT" — the connection is gone, not the command wrong.
return isinstance(exc, aioimaplib.Abort) and "illegal in state" in str(exc)
# Failed connections in a row before an account shows as unreachable, and
# refused sign-ins in a row before its syncing stops ("auth_failed": retrying a
# wrong password only gets the account locked by the provider). Changing the
# password in Settings re-runs the initial sync, which sets it live again.
UNREACHABLE_AFTER = 3
AUTH_GIVE_UP_AFTER = 3
class AccountHealth:
"""One account's run of failures, shared by its sync loops."""
def __init__(self, account_id: uuid.UUID) -> None:
self.account_id = account_id
self.failures = 0
self.auth_failures = 0
# Whether the account is marked unreachable; None = not known yet (a
# mark can survive a worker restart), so the first success checks.
self.marked: bool | None = None
# Set once the sign-in was refused too often: the account is no longer
# synced (state auth_failed), so its loops should stop now.
self.gave_up = False
async def ok(self) -> None:
"""The server answered: reset the counts, and clear an unreachable mark."""
self.failures = 0
self.auth_failures = 0
if self.marked is not False:
await _set_sync_error(self.account_id, None)
self.marked = False
async def failed(self, exc: BaseException) -> None:
if isinstance(exc, ProviderAuthError):
self.auth_failures += 1
if self.auth_failures >= AUTH_GIVE_UP_AFTER:
from app.sync.engine import _set_state
logger.warning(
"account %s: sign-in refused %d times; stopping sync",
self.account_id,
self.auth_failures,
)
await _set_state(self.account_id, "auth_failed", exc.detail[:500])
self.gave_up = True
return
if not is_temporary(exc):
return
self.failures += 1
if self.failures >= UNREACHABLE_AFTER and self.marked is not True:
await _set_sync_error(self.account_id, "unreachable")
self.marked = True
async def _set_sync_error(account_id: uuid.UUID, error: str | None) -> None:
"""Mark (or clear) an account's sync problem; tell the user's open apps."""
from app.db import session_scope
from app.events import get_event_bus
from app.models import Account
now = datetime.now(UTC)
async with session_scope() as session:
cond = Account.sync_error.isnot(None) if error is None else Account.sync_error.is_(None)
user_id = await session.scalar(
update(Account)
.where(Account.id == account_id, cond)
.values(sync_error=error, sync_error_since=None if error is None else now)
.returning(Account.user_id)
)
if user_id is None:
return # nothing changed
logger.warning("account %s: %s", account_id, error or "reachable again")
await get_event_bus().publish(
"account.state_changed",
{"user_id": str(user_id), "account_id": str(account_id), "sync_error": error},
)
+53 -7
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import random
import uuid
from collections.abc import Awaitable, Callable
@@ -26,11 +27,18 @@ from sqlalchemy import select
from app.config import get_settings
from app.db import session_scope
from app.models import Account
from app.providers.base import ProviderAuthError
from app.sync.engine import live_loop, poll_all_loop, secondary_poll_loop
from app.sync.health import AccountHealth, is_temporary
logger = logging.getLogger(__name__)
# After a loop fails: wait 30 s, doubling with each failure in a row up to 10 min
# (±20%, so accounts on one dead server don't retry in step). A loop that ran a
# good while before failing starts again from 30 s.
_RECONNECT_BACKOFF = 30.0
_MAX_BACKOFF = 600.0
_HEALTHY_RUN_S = 300.0
_VERIFY_INTERVAL = 60.0
Tier = str # "hot" | "warm" | "cold"
@@ -49,6 +57,8 @@ class AccountRunner:
# coordinator reaps such runners and releases their lease.
self.dead = asyncio.Event()
self.verify_interval = _VERIFY_INTERVAL
# Failures in a row across this account's loops: unreachable / sign-in failed.
self.health = AccountHealth(account_id)
def start(self) -> None:
"""Launch the verify task and the current tier's work loops."""
@@ -98,13 +108,15 @@ class AccountRunner:
await asyncio.gather(*tasks, return_exceptions=True)
async def _run_live(self) -> None:
await live_loop(self.account_id, "INBOX", self._stop)
await live_loop(self.account_id, "INBOX", self._stop, self.health)
async def _run_secondary(self) -> None:
await secondary_poll_loop(self.account_id, self._stop, get_settings().poll_warm)
await secondary_poll_loop(
self.account_id, self._stop, get_settings().poll_warm, self.health
)
async def _run_poll(self, interval: float) -> None:
await poll_all_loop(self.account_id, interval, self._stop)
await poll_all_loop(self.account_id, interval, self._stop, self.health)
async def _verify_loop(self) -> None:
"""Periodically confirm the account is still live; flag ``dead`` if not.
@@ -129,13 +141,47 @@ class AccountRunner:
return state == "live"
async def _supervise(self, run: Callable[[], Awaitable[None]], label: str) -> None:
"""Run a loop, restarting it (with backoff) until stopped or cancelled."""
"""Run a loop, restarting it (with growing backoff) until stopped or cancelled."""
loop = asyncio.get_running_loop()
fails = 0
while not self._stop.is_set():
started = loop.time()
try:
await run()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("%s loop for account %s crashed; retrying", label, self.account_id)
except Exception as exc:
if loop.time() - started > _HEALTHY_RUN_S:
fails = 0 # it had been working: a fresh failure, not the same outage
fails += 1
delay = min(_MAX_BACKOFF, _RECONNECT_BACKOFF * 2 ** (fails - 1))
delay *= random.uniform(0.8, 1.2)
try:
await self.health.failed(exc)
except Exception: # noqa: BLE001 - never let bookkeeping kill the loop
logger.exception("account %s: recording the failure failed", self.account_id)
if self.health.gave_up:
# Sign-in refused too often: not synced any more (the health
# tracker logged why). Flag the runner so the coordinator
# reaps it and releases the lease.
self.dead.set()
return
if is_temporary(exc) or isinstance(exc, ProviderAuthError):
# Expected while a server is down or a password is wrong: one
# line, not a traceback every half minute.
logger.warning(
"%s loop for account %s: %s; retrying in %.0fs",
label,
self.account_id,
getattr(exc, "detail", None) or exc or type(exc).__name__,
delay,
)
else:
logger.exception(
"%s loop for account %s crashed; retrying in %.0fs",
label,
self.account_id,
delay,
)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._stop.wait(), timeout=_RECONNECT_BACKOFF)
await asyncio.wait_for(self._stop.wait(), timeout=delay)