Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project are documented here.

For upgrade instructions, see [Upgrading](#upgrading) at the bottom.

## [7.12.0] - 2026-06-02

### Added
- **Configurable download timeout** — Media downloads are now wrapped in `asyncio.wait_for` with a `DOWNLOAD_TIMEOUT_SECONDS` budget (default `3600`, `0` disables), so a single stalled download can no longer hang a backup indefinitely.
- **Tunable backoff for transient errors** — `BACKOFF_MIN_SECONDS` and `BACKOFF_MAX_SECONDS` control exponential backoff with jitter for FloodWait and transient network retries, and `FLOOD_WAIT_LOG_THRESHOLD` tunes how chatty FloodWait logging is.

### Fixed
- **Transient network errors no longer abort one-shot API calls** — `call_with_flood_retry` now retries `TimeoutError`/`ConnectionError`/`OSError`/`RPCError` with bounded exponential backoff, while still re-raising terminal errors (FloodWait, FileReferenceExpired, ChannelPrivate, ChatForbidden, UserBanned) immediately.
- **Expired file references are refreshed mid-download** — Downloads that hit `FileReferenceExpiredError` now re-fetch the message and retry instead of failing the media.
- **Concurrent symlink creation is race-safe** — Deduplicated media symlinks tolerate `EEXIST` from concurrent tasks instead of crashing.
- **`upsert_user` and `insert_media` retry on locked DB** — Both now use `@retry_on_locked()` for resilience under concurrent SQLite access.
- **Windows-friendly auth help** — `setup_auth` no longer calls `os.getuid()`/`os.getgid()` unconditionally.

### Credits
- Thanks to [@smbdspk](https://github.com/smbdspk) for the download-resilience work in [#180](https://github.com/GeiserX/Telegram-Archive/pull/180).

## [7.10.10] - 2026-05-24

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "telegram-archive"
version = "7.11.7"
version = "7.12.0"
description = "Automated Telegram backup with Docker. Performs incremental backups of messages and media on a configurable schedule."
readme = "README.md"
requires-python = ">=3.14"
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Telegram Backup Automation - Main Package
"""

__version__ = "7.11.7"
__version__ = "7.12.0"
4 changes: 0 additions & 4 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,6 @@ def __init__(self):
# Default increased to 60s for better resilience with concurrent access (backup + web viewer).
self.database_timeout = float(os.getenv("DATABASE_TIMEOUT", "60.0"))

# Backoff intervals for connection/timeout retries.
self.backoff_min = float(os.getenv("BACKOFF_MIN_SECONDS", "2.0"))
self.backoff_max = float(os.getenv("BACKOFF_MAX_SECONDS", "300.0"))

# =====================================================================
# CHAT FILTERING - Two Modes
# =====================================================================
Expand Down
2 changes: 1 addition & 1 deletion src/db/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1119,7 +1119,7 @@ async def get_cached_statistics(self) -> dict[str, Any]:

try:
result.update(json.loads(cached_stats))
except json.JSONDecodeError:
except json.JSONDecodeError, TypeError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the syntax at the changed line and confirm parseability.
set -euo pipefail

fd -p src/db/adapter.py
python - <<'PY'
import ast, pathlib
p = pathlib.Path("src/db/adapter.py")
src = p.read_text(encoding="utf-8")
ast.parse(src)
print("OK: src/db/adapter.py parses successfully")
PY

Repository: GeiserX/Telegram-Archive

Length of output: 509


Fix invalid Python except tuple syntax (parse-time failure).

src/db/adapter.py:1122 uses except json.JSONDecodeError, TypeError: which is invalid in Python 3 (“multiple exception types must be parenthesized”).

Proposed fix
-            except json.JSONDecodeError, TypeError:
+            except (json.JSONDecodeError, TypeError):
                 pass
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/adapter.py` at line 1122, The except clause using invalid Python
syntax `except json.JSONDecodeError, TypeError:` must be replaced with a proper
tuple or separate handlers; update the exception handling around the JSON
parsing in src/db/adapter.py to use `except (json.JSONDecodeError, TypeError):`
(or separate `except json.JSONDecodeError:` and `except TypeError:`) so both
exceptions are caught correctly under Python 3, keeping the existing error
handling logic in the same block where the JSON parsing occurs.

pass

if last_backup_time:
Expand Down
9 changes: 5 additions & 4 deletions src/telegram_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1743,7 +1743,7 @@ async def _download_fn(tmp_path):
raise
except TimeoutError:
logger.error(
f"Download timed out after {self.config.download_timeout_seconds} seconds for message {message.id} (attempt {attempt + 1}/3)"
f"Download timed out after {timeout} seconds for message {message.id} (attempt {attempt + 1}/3)"
)
if attempt == 2:
raise
Expand Down Expand Up @@ -1776,11 +1776,12 @@ async def _download_fn(tmp_path):
if not os.path.lexists(file_path):
task_id = id(asyncio.current_task()) if asyncio.current_task() else 0
tmp_file_path = f"{file_path}.{os.getpid()}.{task_id}.part"
if os.path.exists(tmp_file_path):
os.remove(tmp_file_path)
timeout = getattr(self.config, "download_timeout_seconds", 3600)
timeout_val = timeout if isinstance(timeout, int) and timeout > 0 else None
for attempt in range(3):
# Clear any partial file from a prior attempt so each retry starts clean.
if os.path.exists(tmp_file_path):
os.remove(tmp_file_path)
try:
actual_path = await asyncio.wait_for(
call_with_flood_retry(self.client.download_media, message, tmp_file_path),
Expand All @@ -1798,7 +1799,7 @@ async def _download_fn(tmp_path):
raise
except TimeoutError:
logger.error(
f"Download timed out after {self.config.download_timeout_seconds} seconds for message {message.id} (attempt {attempt + 1}/3)"
f"Download timed out after {timeout} seconds for message {message.id} (attempt {attempt + 1}/3)"
)
if attempt == 2:
raise
Expand Down
Loading