From de54458fd74156cc56fee4ee9c54e848e72ff735 Mon Sep 17 00:00:00 2001 From: Guillem Hernandez Sola Date: Thu, 7 May 2026 09:34:16 +0200 Subject: [PATCH] retries --- bsky_post.py | 101 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/bsky_post.py b/bsky_post.py index ef9e6c4..578cd3f 100644 --- a/bsky_post.py +++ b/bsky_post.py @@ -16,11 +16,22 @@ import os import sys import time import random -import re +from dataclasses import dataclass from atproto import Client, client_utils, models +# ============================================================ +# Config +# ============================================================ +@dataclass(frozen=True) +class RetryConfig: + login_max_attempts: int = 5 + login_base_delay_seconds: float = 2.0 + login_max_delay_seconds: float = 600.0 + login_jitter_seconds: float = 1.5 + + # ============================================================ # Logging # ============================================================ @@ -83,25 +94,31 @@ def is_rate_limited_error(e) -> bool: def is_auth_error(e) -> bool: text = repr(e).lower() - return any(s in text for s in ["401", "403", "invalid identifier or password", - "authenticationrequired", "invalidtoken"]) + return any(s in text for s in [ + "401", "403", "invalid identifier or password", + "authenticationrequired", "invalidtoken", + ]) def is_network_error(e) -> bool: text = repr(e) - return any(s in text for s in ["ConnectError", "RemoteProtocolError", "ReadTimeout", - "WriteTimeout", "TimeoutException", "503", "502", "504", - "ConnectionResetError"]) + return any(s in text for s in [ + "ConnectError", "RemoteProtocolError", "ReadTimeout", + "WriteTimeout", "TimeoutException", "503", "502", "504", + "ConnectionResetError", + ]) def is_timeout_error(e) -> bool: text = repr(e) - return any(s in text for s in ["InvokeTimeoutError", "ReadTimeout", - "WriteTimeout", "TimeoutException"]) + return any(s in text for s in [ + "InvokeTimeoutError", "ReadTimeout", + "WriteTimeout", "TimeoutException", + ]) # ============================================================ -# Login with backoff (same pattern as rss2bsky / twitter bot) +# Login with backoff # ============================================================ def login_with_backoff( client: Client, @@ -110,7 +127,7 @@ def login_with_backoff( service_url: str, max_attempts: int = 5, base_delay: float = 2.0, - max_delay: float = 120.0, + max_delay: float = 600.0, jitter: float = 1.5, ) -> bool: for attempt in range(1, max_attempts + 1): @@ -125,32 +142,25 @@ def login_with_backoff( except Exception as e: logging.exception("❌ Login exception") - if is_rate_limited_error(e): - if attempt < max_attempts: - delay = min(base_delay * (2 ** (attempt - 1)), max_delay) + random.uniform(0, jitter) - logging.warning(f"⏳ Rate-limited on login. Retrying in {delay:.1f}s...") - time.sleep(delay) - continue - logging.error("❌ Login rate-limited and retries exhausted.") - return False - if is_auth_error(e): logging.error("❌ Bad credentials. Check handle/password.") return False - if attempt < max_attempts and (is_network_error(e) or is_timeout_error(e)): + if attempt >= max_attempts: + logging.error("❌ Login retries exhausted.") + return False + + if is_rate_limited_error(e): + delay = min(base_delay * (2 ** (attempt - 1)), max_delay) + random.uniform(0, jitter) + logging.warning(f"⏳ Rate-limited on login. Retrying in {delay:.1f}s...") + elif is_network_error(e) or is_timeout_error(e): delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) logging.warning(f"⏳ Transient login error. Retrying in {delay:.1f}s...") - time.sleep(delay) - continue - - if attempt < max_attempts: + else: delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) logging.warning(f"⏳ Unknown login error. Retrying in {delay:.1f}s...") - time.sleep(delay) - continue - return False + time.sleep(delay) return False @@ -173,7 +183,11 @@ def detect_mime_type(path: str) -> str: return fallbacks.get(ext, "application/octet-stream") -def upload_image(client: Client, image_path: str, alt_text: str = "") -> models.AppBskyEmbedImages.Image | None: +def upload_image( + client: Client, + image_path: str, + alt_text: str = "", +) -> models.AppBskyEmbedImages.Image | None: try: mime = detect_mime_type(image_path) with open(image_path, "rb") as f: @@ -193,7 +207,11 @@ def upload_image(client: Client, image_path: str, alt_text: str = "") -> models. return None -def upload_video(client: Client, video_path: str, alt_text: str = "") -> models.AppBskyEmbedVideo.Main | None: +def upload_video( + client: Client, + video_path: str, + alt_text: str = "", +) -> models.AppBskyEmbedVideo.Main | None: try: mime = detect_mime_type(video_path) with open(video_path, "rb") as f: @@ -262,14 +280,14 @@ def main(): parser = argparse.ArgumentParser( description="Post text + optional image or video to a Bluesky instance." ) - parser.add_argument("text", help="Post text content") - parser.add_argument("--username", required=True, help="Bluesky handle or email") - parser.add_argument("--password", required=True, help="Bluesky app password") - parser.add_argument("--service", default="https://eurosky.social", help="Bluesky PDS URL") - parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)") - parser.add_argument("--image", default=None, help="Path to image file to attach") - parser.add_argument("--video", default=None, help="Path to video file to attach") - parser.add_argument("--alt", default="", help="Alt text for the attached media") + parser.add_argument("text", help="Post text content") + parser.add_argument("--username", required=True, help="Bluesky handle or email") + parser.add_argument("--password", required=True, help="Bluesky app password") + parser.add_argument("--service", default="https://eurosky.social", help="Bluesky PDS URL") + parser.add_argument("--lang", default="ca", help="Comma-separated language codes (e.g. ca,es)") + parser.add_argument("--image", default=None, help="Path to image file to attach") + parser.add_argument("--video", default=None, help="Path to video file to attach") + parser.add_argument("--alt", default="", help="Alt text for the attached media") args = parser.parse_args() if args.image and args.video: @@ -284,16 +302,23 @@ def main(): logging.error(f"❌ Video file not found: {args.video}") sys.exit(1) - langs = [l.strip() for l in args.lang.split(",") if l.strip()] or ["ca"] + langs = [lang.strip() for lang in args.lang.split(",") if lang.strip()] or ["ca"] logging.info(f"🌍 Language(s): {langs}") + cfg = RetryConfig() client = Client(base_url=args.service) + logged_in = login_with_backoff( client=client, username=args.username, password=args.password, service_url=args.service, + max_attempts=cfg.login_max_attempts, + base_delay=cfg.login_base_delay_seconds, + max_delay=cfg.login_max_delay_seconds, + jitter=cfg.login_jitter_seconds, ) + if not logged_in: logging.error("❌ Could not log in. Exiting.") sys.exit(1)