diff --git a/rss2bsky.py b/rss2bsky.py index ab7f251..e3b5a0f 100644 --- a/rss2bsky.py +++ b/rss2bsky.py @@ -5,6 +5,7 @@ import logging import re import httpx import time +import random import charset_normalizer import sys import os @@ -55,6 +56,12 @@ class RetryConfig: blob_transient_error_delay: int = 10 post_retry_delay_seconds: int = 2 + # Login hardening + login_max_attempts: int = 4 + login_base_delay_seconds: int = 10 + login_max_delay_seconds: int = 600 + login_jitter_seconds: float = 1.5 + @dataclass(frozen=True) class CooldownConfig: @@ -498,29 +505,59 @@ def make_rich(content: str): # Error helpers # ============================================================ def get_rate_limit_reset_timestamp(error_obj): + # 1) direct headers try: - headers = getattr(error_obj, "headers", None) - if headers: - reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") - if reset_value: - return int(reset_value) + headers = getattr(error_obj, "headers", None) or {} + now_ts = int(time.time()) + + retry_after = headers.get("retry-after") or headers.get("Retry-After") + if retry_after: + return now_ts + int(retry_after) + + x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") + if x_after: + return now_ts + int(x_after) + + reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") + if reset_value: + return int(reset_value) except Exception: pass + # 2) headers nested in response try: response = getattr(error_obj, "response", None) - headers = getattr(response, "headers", None) - if headers: - reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") - if reset_value: - return int(reset_value) + 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 now_ts + int(retry_after) + + x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") + if x_after: + return now_ts + int(x_after) + + reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") + if reset_value: + return int(reset_value) except Exception: pass + # 3) fallback parse text = repr(error_obj) - match = re.search(r"'ratelimit-reset': '(\d+)'", text) - if match: - return int(match.group(1)) + + m = re.search(r"'retry-after': '(\d+)'", text, re.IGNORECASE) + if m: + return int(time.time()) + int(m.group(1)) + + m = re.search(r"'x-ratelimit-after': '(\d+)'", text, re.IGNORECASE) + if m: + return int(time.time()) + int(m.group(1)) + + m = re.search(r"'ratelimit-reset': '(\d+)'", text, re.IGNORECASE) + if m: + return int(m.group(1)) return None @@ -532,7 +569,9 @@ def is_rate_limited_error(error_obj) -> bool: "429" in error_text or "429" in repr_text or "RateLimitExceeded" in error_text or - "RateLimitExceeded" in repr_text + "RateLimitExceeded" in repr_text or + "Too Many Requests" in error_text or + "Too Many Requests" in repr_text ) @@ -559,6 +598,26 @@ def is_probable_length_error(exc) -> bool: return any(signal.lower() in text.lower() for signal in signals) +def is_auth_error(error_obj) -> bool: + 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) -> bool: + text = repr(error_obj) + signals = [ + "ConnectError", "RemoteProtocolError", "ReadTimeout", "WriteTimeout", + "TimeoutException", "503", "502", "504", "ConnectionResetError" + ] + return any(s in text for s in signals) + + def activate_post_creation_cooldown_from_error(error_obj, cooldown_path: str, cfg: AppConfig) -> int: reset_ts = get_rate_limit_reset_timestamp(error_obj) if not reset_ts: @@ -785,7 +844,7 @@ def compress_external_thumb_to_limit(image_bytes: bytes, cfg: AppConfig): img = img.resize(new_size, Image.LANCZOS) logging.info(f"🖼️ Resized external thumb to {new_size[0]}x{new_size[1]}") - best_so_far = None # explicit fix + best_so_far = None for quality in [78, 70, 62, 54, 46, 40, cfg.limits.external_thumb_min_jpeg_quality]: out = io.BytesIO() @@ -997,20 +1056,61 @@ def build_candidates_from_feed(feed) -> List[EntryCandidate]: # ============================================================ # Orchestration # ============================================================ -def login_with_backoff(client: Client, bsky_username: str, bsky_password: str, service_url: str): - backoff = 60 - while True: +def login_with_backoff( + client: Client, + bsky_username: str, + bsky_password: str, + service_url: str, + cooldown_path: str, + cfg: AppConfig +) -> bool: + if check_post_cooldown_or_log(cooldown_path): + return False + + max_attempts = cfg.retry.login_max_attempts + base_delay = cfg.retry.login_base_delay_seconds + max_delay = cfg.retry.login_max_delay_seconds + jitter_max = max(cfg.retry.login_jitter_seconds, 0.0) + + for attempt in range(1, max_attempts + 1): try: - if check_post_cooldown_or_log(args.cooldown_path): + if check_post_cooldown_or_log(cooldown_path): return False - logging.info(f"🔐 Attempting login to server: {service_url} with user: {bsky_username}") + + logging.info( + f"🔐 Attempting login to server: {service_url} " + f"with user: {bsky_username} (attempt {attempt}/{max_attempts})" + ) client.login(bsky_username, bsky_password) logging.info(f"✅ Login successful for user: {bsky_username}") return True - except Exception: + + except Exception as e: logging.exception("❌ Login exception") - time.sleep(backoff) - backoff = min(backoff + 60, 600) + + if is_rate_limited_error(e): + activate_post_creation_cooldown_from_error(e, cooldown_path, cfg) + return False + + if is_auth_error(e): + logging.error("❌ Authentication failed (bad handle/password/app-password).") + return False + + if attempt < max_attempts and (is_network_error(e) or is_timeout_error(e)): + delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter_max) + logging.warning(f"⏳ Transient login failure. Retrying in {delay:.1f}s...") + time.sleep(delay) + continue + + if attempt < max_attempts: + delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter_max) + logging.warning(f"⏳ Login retry in {delay:.1f}s...") + time.sleep(delay) + continue + + return False + + return False def run_once( @@ -1031,19 +1131,19 @@ def run_once( return RunResult(published_count=0, stopped_reason="global_post_cooldown_active") client = Client(base_url=service_url) - backoff = 60 - while True: - try: - if check_post_cooldown_or_log(cooldown_path): - return RunResult(published_count=0, stopped_reason="global_post_cooldown_active") - logging.info(f"🔐 Attempting login to server: {service_url} with user: {bsky_username}") - client.login(bsky_username, bsky_password) - logging.info(f"✅ Login successful for user: {bsky_username}") - break - except Exception: - logging.exception("❌ Login exception") - time.sleep(backoff) - backoff = min(backoff + 60, 600) + + logged_in = login_with_backoff( + client=client, + bsky_username=bsky_username, + bsky_password=bsky_password, + service_url=service_url, + cooldown_path=cooldown_path, + cfg=cfg + ) + if not logged_in: + if check_post_cooldown_or_log(cooldown_path): + return RunResult(published_count=0, stopped_reason="global_post_cooldown_active") + return RunResult(published_count=0, stopped_reason="login_failed") state = load_state(state_path) recent_bsky_posts = get_recent_bsky_posts(client, bsky_handle, limit=cfg.limits.dedupe_bsky_limit) @@ -1197,4 +1297,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/testlogin.py b/testlogin.py index 2fa0617..b09d25d 100644 --- a/testlogin.py +++ b/testlogin.py @@ -1,46 +1,200 @@ import argparse import logging +import os +import random +import sys import time + +import httpx from atproto import Client # --- Logging --- -LOG_PATH = "rss2bsky_test.log" +LOG_PATH = "bsky_login_test.log" logging.basicConfig( - format="%(asctime)s %(message)s", - filename=LOG_PATH, - encoding="utf-8", + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(LOG_PATH, encoding="utf-8"), + logging.StreamHandler(), + ], level=logging.INFO, ) -def main(): - # --- Parse command-line arguments --- - parser = argparse.ArgumentParser(description="Post RSS to Bluesky.") - parser.add_argument("rss_feed", help="RSS feed URL") - parser.add_argument("bsky_handle", help="Bluesky handle") - parser.add_argument("bsky_username", help="Bluesky username") - parser.add_argument("bsky_app_password", help="Bluesky app password") - parser.add_argument("--service", default="https://bsky.social", help="Bluesky server URL (default: https://bsky.social)") - - args = parser.parse_args() - bsky_username = args.bsky_username - bsky_password = args.bsky_app_password - service_url = args.service +EXIT_OK = 0 +EXIT_BAD_CREDS = 2 +EXIT_RATE_LIMIT = 3 +EXIT_NETWORK = 4 +EXIT_OTHER = 5 - # --- Login --- - # SOLUCIÓ: Passem el base_url directament al constructor del Client - client = Client(base_url=service_url) - - backoff = 60 - while True: + +def parse_wait_seconds_from_exception(exc, default_delay=15, max_delay=900): + """ + Parse common rate-limit headers from atproto exceptions: + - retry-after (seconds) + - x-ratelimit-after (seconds) + - ratelimit-reset (unix timestamp) + """ + try: + headers = getattr(exc, "headers", None) or {} + + retry_after = headers.get("retry-after") or headers.get("Retry-After") + if retry_after: + return min(max(int(retry_after), 1), 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), max_delay) + + reset = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") + if reset: + wait_s = max(int(reset) - int(time.time()) + 1, 1) + return min(wait_s, max_delay) + + except Exception: + pass + + return default_delay + + +def classify_error(exc): + """ + Classify exception into: + - rate_limit + - bad_creds + - network + - other + """ + text = repr(exc).lower() + status_code = getattr(exc, "status_code", None) + + if status_code == 429 or "429" in text or "too many requests" in text or "ratelimit" in text: + return "rate_limit" + + if status_code in (401, 403) or "invalid identifier or password" in text or "authentication" in text: + return "bad_creds" + + transient_signals = [ + "timeout", + "connecterror", + "remoteprotocolerror", + "readtimeout", + "writetimeout", + "503", + "502", + "504", + "connection", + ] + if any(sig in text for sig in transient_signals): + return "network" + + return "other" + + +def preflight_health(service_url, timeout=8): + url = f"{service_url.rstrip('/')}/xrpc/_health" + try: + r = httpx.get(url, timeout=timeout) + logging.info(f"🩺 Health check {url} -> HTTP {r.status_code}") + return True + except Exception as e: + logging.warning(f"🩺 Health check failed: {e}") + return False + + +def build_client(service_url): + normalized = service_url.strip().rstrip("/") + + try: + return Client(base_url=normalized) + except TypeError: + logging.warning("⚠️ Client(base_url=...) unsupported in this atproto version. Falling back.") + c = Client() try: - logging.info(f"Attempting login to server: {service_url} with user: {bsky_username}") - client.login(bsky_username, bsky_password) - logging.info(f"Login successful for user: {bsky_username}") - break + if hasattr(c, "base_url"): + c.base_url = normalized + elif hasattr(c, "_base_url"): + c._base_url = normalized except Exception as e: - logging.exception("Login exception") - time.sleep(backoff) - backoff = min(backoff + 60, 600) + logging.warning(f"⚠️ Could not apply custom base URL: {e}") + return c + + +def main(): + parser = argparse.ArgumentParser(description="Bluesky login test only.") + parser.add_argument("--bsky-handle", required=True, help="Bluesky handle (e.g. user.example.social)") + parser.add_argument( + "--bsky-app-password", + default=None, + help="Bluesky app password (prefer env BSKY_APP_PASSWORD)", + ) + parser.add_argument( + "--service", + default="https://bsky.social", + help="PDS base URL (default: https://bsky.social)", + ) + parser.add_argument("--max-attempts", type=int, default=3, help="Retry attempts (default: 3)") + parser.add_argument("--base-delay", type=int, default=10, help="Base retry delay in seconds (default: 10)") + parser.add_argument("--jitter-max", type=float, default=2.0, help="Random jitter max seconds (default: 2.0)") + args = parser.parse_args() + + handle = args.bsky_handle.strip() + service_url = args.service.strip().rstrip("/") + app_password = (args.bsky_app_password or os.getenv("BSKY_APP_PASSWORD", "")).strip() + + if not app_password: + logging.error("❌ Missing app password. Use --bsky-app-password or env BSKY_APP_PASSWORD.") + print("LOGIN_FAILED_BAD_CREDS") + sys.exit(EXIT_BAD_CREDS) + + logging.info(f"🔐 Testing login against: {service_url}") + logging.info(f"👤 Handle: {handle}") + + # Optional but useful diagnostics + preflight_health(service_url) + + client = build_client(service_url) + + last_kind = "other" + + for attempt in range(1, args.max_attempts + 1): + try: + logging.info(f"➡️ Login attempt {attempt}/{args.max_attempts}") + client.login(handle, app_password) + logging.info("✅ Login successful.") + print("LOGIN_OK") + sys.exit(EXIT_OK) + + except Exception as e: + last_kind = classify_error(e) + logging.exception(f"❌ Login failed [{last_kind}]") + + if last_kind == "bad_creds": + print("LOGIN_FAILED_BAD_CREDS") + sys.exit(EXIT_BAD_CREDS) + + if attempt >= args.max_attempts: + break + + if last_kind == "rate_limit": + wait_s = parse_wait_seconds_from_exception(e, default_delay=args.base_delay) + elif last_kind == "network": + wait_s = min(args.base_delay * attempt, 60) + else: + wait_s = min(args.base_delay * attempt, 45) + + wait_s = wait_s + random.uniform(0, max(args.jitter_max, 0.0)) + logging.warning(f"⏳ Waiting {wait_s:.1f}s before retry...") + time.sleep(wait_s) + + if last_kind == "rate_limit": + print("LOGIN_FAILED_RATE_LIMIT") + sys.exit(EXIT_RATE_LIMIT) + if last_kind == "network": + print("LOGIN_FAILED_NETWORK") + sys.exit(EXIT_NETWORK) + + print("LOGIN_FAILED") + sys.exit(EXIT_OTHER) + if __name__ == "__main__": - main() + main() \ No newline at end of file