diff --git a/twitter2bsky_daemon.py b/twitter2bsky_daemon.py index 37b1f49..f3d2ca9 100644 --- a/twitter2bsky_daemon.py +++ b/twitter2bsky_daemon.py @@ -11,6 +11,7 @@ import time import os import subprocess import uuid +import random from urllib.parse import urlparse from dotenv import load_dotenv from atproto import Client, client_utils, models @@ -49,6 +50,12 @@ BSKY_SEND_POST_MAX_RETRIES = 3 BSKY_SEND_POST_BASE_DELAY = 5 BSKY_SEND_POST_MAX_DELAY = 60 +# --- Login hardening (NEW) --- +BSKY_LOGIN_MAX_RETRIES = 4 +BSKY_LOGIN_BASE_DELAY = 10 +BSKY_LOGIN_MAX_DELAY = 600 +BSKY_LOGIN_JITTER_MAX = 1.5 + MEDIA_DOWNLOAD_TIMEOUT = 30 LINK_METADATA_TIMEOUT = 10 URL_RESOLVE_TIMEOUT = 12 @@ -1336,30 +1343,113 @@ def build_text_media_key(normalized_text, media_fingerprint): ).hexdigest() +# --- Login hardening helpers (NEW) --- +def is_rate_limited_error(error_obj): + text = repr(error_obj).lower() + return ( + "429" in text + or "ratelimitexceeded" in text + or "too many requests" in text + or "rate limit" in text + ) + + +def is_auth_error(error_obj): + text = repr(error_obj).lower() + return ( + "401" in text + or "403" in text + or "invalid identifier or password" in text + or "authenticationrequired" in text + or "invalidtoken" in text + ) + + +def is_network_error(error_obj): + text = repr(error_obj) + signals = [ + "ConnectError", + "RemoteProtocolError", + "ReadTimeout", + "WriteTimeout", + "TimeoutException", + "503", + "502", + "504", + "ConnectionResetError", + ] + return any(sig in text for sig in signals) + + def create_bsky_client(base_url, handle, password): normalized_base_url = (base_url or DEFAULT_BSKY_BASE_URL).strip().rstrip("/") logging.info(f"🔐 Connecting Bluesky client via base URL: {normalized_base_url}") client = Client(base_url=normalized_base_url) - max_retries = 3 - for attempt in range(1, max_retries + 1): + max_attempts = BSKY_LOGIN_MAX_RETRIES + base_delay = BSKY_LOGIN_BASE_DELAY + max_delay = BSKY_LOGIN_MAX_DELAY + jitter_max = max(BSKY_LOGIN_JITTER_MAX, 0.0) + + for attempt in range(1, max_attempts + 1): try: + logging.info(f"🔐 Bluesky login attempt {attempt}/{max_attempts} for {handle}") client.login(handle, password) + logging.info("✅ Bluesky login successful.") return client + except Exception as e: - msg = str(e) - is_rate = ("429" in msg) or ("RateLimitExceeded" in msg) - if is_rate and attempt < max_retries: - wait = get_rate_limit_wait_seconds(e, default_delay=60) + logging.exception("❌ Bluesky login exception") + + # Fail fast on invalid credentials + if is_auth_error(e): + logging.error("❌ Bluesky auth failed (invalid handle/app password).") + raise + + # Respect explicit rate-limit timing + if is_rate_limited_error(e): + if attempt < max_attempts: + wait = get_rate_limit_wait_seconds(e, default_delay=base_delay) + wait = wait + random.uniform(0, jitter_max) + logging.warning( + f"⏳ Bluesky login rate-limited (attempt {attempt}/{max_attempts}). " + f"Retrying in {wait:.1f}s." + ) + time.sleep(wait) + continue + + logging.error("❌ Exhausted Bluesky login retries due to rate limiting.") + raise + + # Retry transient/network problems + if is_network_error(e) or is_transient_error(e): + if attempt < max_attempts: + wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter_max) + logging.warning( + f"⏳ Transient Bluesky login failure (attempt {attempt}/{max_attempts}). " + f"Retrying in {wait:.1f}s." + ) + time.sleep(wait) + continue + + logging.error("❌ Exhausted Bluesky login retries after transient/network errors.") + raise + + # Unknown errors: bounded retry anyway + if attempt < max_attempts: + wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter_max) logging.warning( - f"⏳ Login rate-limited (attempt {attempt}/{max_retries}). " - f"Sleeping {wait}s before retry." + f"⏳ Bluesky login retry for unexpected error " + f"(attempt {attempt}/{max_attempts}) in {wait:.1f}s." ) time.sleep(wait) continue + raise + raise RuntimeError("Bluesky login failed after all retries.") + # --- State Management --- def default_state(): @@ -1575,20 +1665,70 @@ def get_recent_bsky_posts(client, handle, limit=30): # --- Upload / Retry Helpers --- def get_rate_limit_wait_seconds(error_obj, default_delay): + """ + Parse common rate-limit headers and return a bounded wait time in seconds. + Supports: + - retry-after + - x-ratelimit-after + - ratelimit-reset (unix timestamp) + """ try: - headers = getattr(error_obj, "headers", None) - if headers: - reset_value = headers.get("ratelimit-reset") or headers.get( - "RateLimit-Reset" - ) - if reset_value: - now_ts = int(time.time()) - reset_ts = int(reset_value) - wait_seconds = max(reset_ts - now_ts + 1, default_delay) - return min(wait_seconds, BSKY_BLOB_UPLOAD_MAX_DELAY) + now_ts = int(time.time()) + + # Direct headers on exception + headers = getattr(error_obj, "headers", None) or {} + retry_after = headers.get("retry-after") or headers.get("Retry-After") + if retry_after: + return min(max(int(retry_after), 1), BSKY_LOGIN_MAX_DELAY) + + x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") + if x_after: + return min(max(int(x_after), 1), BSKY_LOGIN_MAX_DELAY) + + reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") + if reset_value: + wait_seconds = max(int(reset_value) - now_ts + 1, default_delay) + return min(wait_seconds, BSKY_LOGIN_MAX_DELAY) except Exception: pass + try: + # Nested response headers + response = getattr(error_obj, "response", None) + headers = getattr(response, "headers", None) or {} + now_ts = int(time.time()) + + retry_after = headers.get("retry-after") or headers.get("Retry-After") + if retry_after: + return min(max(int(retry_after), 1), BSKY_LOGIN_MAX_DELAY) + + x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") + if x_after: + return min(max(int(x_after), 1), BSKY_LOGIN_MAX_DELAY) + + reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") + if reset_value: + wait_seconds = max(int(reset_value) - now_ts + 1, default_delay) + return min(wait_seconds, BSKY_LOGIN_MAX_DELAY) + except Exception: + pass + + # repr fallback parsing + text = repr(error_obj) + m = re.search(r"'retry-after': '(\d+)'", text, re.IGNORECASE) + if m: + return min(max(int(m.group(1)), 1), BSKY_LOGIN_MAX_DELAY) + + m = re.search(r"'x-ratelimit-after': '(\d+)'", text, re.IGNORECASE) + if m: + return min(max(int(m.group(1)), 1), BSKY_LOGIN_MAX_DELAY) + + m = re.search(r"'ratelimit-reset': '(\d+)'", text, re.IGNORECASE) + if m: + now_ts = int(time.time()) + wait_seconds = max(int(m.group(1)) - now_ts + 1, default_delay) + return min(wait_seconds, BSKY_LOGIN_MAX_DELAY) + return default_delay