diff --git a/bsky_post.py b/bsky_post.py index 578cd3f..910cc4d 100644 --- a/bsky_post.py +++ b/bsky_post.py @@ -16,6 +16,7 @@ import os import sys import time import random +import re from dataclasses import dataclass from atproto import Client, client_utils, models @@ -27,7 +28,7 @@ from atproto import Client, client_utils, models @dataclass(frozen=True) class RetryConfig: login_max_attempts: int = 5 - login_base_delay_seconds: float = 2.0 + login_base_delay_seconds: float = 10.0 login_max_delay_seconds: float = 600.0 login_jitter_seconds: float = 1.5 @@ -87,34 +88,125 @@ def make_rich(content: str): # ============================================================ # Error helpers # ============================================================ -def is_rate_limited_error(e) -> bool: - text = repr(e) - return any(s in text for s in ["429", "RateLimitExceeded", "Too Many Requests"]) +def is_rate_limited_error(error_obj) -> bool: + 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(e) -> bool: - text = repr(e).lower() - return any(s in text for s in [ - "401", "403", "invalid identifier or password", - "authenticationrequired", "invalidtoken", - ]) +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(e) -> bool: - text = repr(e) - return any(s in text for s in [ - "ConnectError", "RemoteProtocolError", "ReadTimeout", - "WriteTimeout", "TimeoutException", "503", "502", "504", +def is_network_error(error_obj) -> bool: + text = repr(error_obj) + signals = [ + "ConnectError", + "RemoteProtocolError", + "ReadTimeout", + "WriteTimeout", + "TimeoutException", + "503", + "502", + "504", "ConnectionResetError", - ]) + ] + return any(sig in text for sig in signals) -def is_timeout_error(e) -> bool: - text = repr(e) - return any(s in text for s in [ - "InvokeTimeoutError", "ReadTimeout", - "WriteTimeout", "TimeoutException", - ]) +def is_transient_error(error_obj) -> bool: + error_text = repr(error_obj) + transient_signals = [ + "InvokeTimeoutError", + "ReadTimeout", + "WriteTimeout", + "TimeoutException", + "RemoteProtocolError", + "ConnectError", + "503", + "502", + "504", + ] + return any(signal in error_text for signal in transient_signals) + + +def get_rate_limit_wait_seconds(error_obj, default_delay: float, max_delay: float) -> float: + """ + Parse common rate-limit headers and return a bounded wait time in seconds. + Supports: + - retry-after + - x-ratelimit-after + - ratelimit-reset (unix timestamp) + """ + try: + 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(float(retry_after), 1.0), max_delay) + + x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") + if x_after: + return min(max(float(x_after), 1.0), max_delay) + + reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") + if reset_value: + wait_seconds = max(float(reset_value) - now_ts + 1.0, default_delay) + return min(wait_seconds, 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(float(retry_after), 1.0), max_delay) + + x_after = headers.get("x-ratelimit-after") or headers.get("X-RateLimit-After") + if x_after: + return min(max(float(x_after), 1.0), max_delay) + + reset_value = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset") + if reset_value: + wait_seconds = max(float(reset_value) - now_ts + 1.0, default_delay) + return min(wait_seconds, 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(float(m.group(1)), 1.0), max_delay) + + m = re.search(r"'x-ratelimit-after': '(\d+)'", text, re.IGNORECASE) + if m: + return min(max(float(m.group(1)), 1.0), max_delay) + + m = re.search(r"'ratelimit-reset': '(\d+)'", text, re.IGNORECASE) + if m: + now_ts = int(time.time()) + wait_seconds = max(float(m.group(1)) - now_ts + 1.0, default_delay) + return min(wait_seconds, max_delay) + + return default_delay # ============================================================ @@ -126,14 +218,14 @@ def login_with_backoff( password: str, service_url: str, max_attempts: int = 5, - base_delay: float = 2.0, + base_delay: float = 10.0, max_delay: float = 600.0, jitter: float = 1.5, ) -> bool: for attempt in range(1, max_attempts + 1): try: logging.info( - f"🔐 Login attempt {attempt}/{max_attempts} → {service_url} as {username}" + f"🔑 Login attempt {attempt}/{max_attempts} → {service_url} as {username}" ) client.login(username, password) logging.info("✅ Login successful.") @@ -142,25 +234,49 @@ def login_with_backoff( except Exception as e: logging.exception("❌ Login exception") + # Fail fast on invalid credentials if is_auth_error(e): logging.error("❌ Bad credentials. Check handle/password.") return False - if attempt >= max_attempts: - logging.error("❌ Login retries exhausted.") + # 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, max_delay=max_delay) + wait = wait + random.uniform(0, jitter) + logging.warning( + f"⏳ Rate-limited on login (attempt {attempt}/{max_attempts}). " + f"Retrying in {wait:.1f}s..." + ) + time.sleep(wait) + continue + + logging.error("❌ Exhausted login retries due to rate limiting.") 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...") - else: - delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) - logging.warning(f"⏳ Unknown login error. Retrying in {delay:.1f}s...") + # 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) + logging.warning( + f"⏳ Transient login error (attempt {attempt}/{max_attempts}). " + f"Retrying in {wait:.1f}s..." + ) + time.sleep(wait) + continue - time.sleep(delay) + logging.error("❌ Exhausted login retries after transient/network errors.") + return False + + # Unknown errors: bounded retry anyway + if attempt < max_attempts: + wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter) + logging.warning( + f"⏳ Unknown login error (attempt {attempt}/{max_attempts}). " + f"Retrying in {wait:.1f}s..." + ) + time.sleep(wait) + continue return False @@ -283,48 +399,31 @@ def main(): 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("--service", default="https://bsky.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("--alt", default="", help="Alt text for media") + args = parser.parse_args() - if args.image and args.video: - logging.error("❌ Cannot attach both --image and --video at the same time.") - sys.exit(1) - - if args.image and not os.path.isfile(args.image): - logging.error(f"❌ Image file not found: {args.image}") - sys.exit(1) - - if args.video and not os.path.isfile(args.video): - logging.error(f"❌ Video file not found: {args.video}") - sys.exit(1) - - 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, + success = login_with_backoff( + client, + args.username, + args.password, + args.service, + max_attempts=RetryConfig.login_max_attempts, + base_delay=RetryConfig.login_base_delay_seconds, + max_delay=RetryConfig.login_max_delay_seconds, + jitter=RetryConfig.login_jitter_seconds, ) - - if not logged_in: - logging.error("❌ Could not log in. Exiting.") + if not success: sys.exit(1) - success = post_to_bsky( - client=client, + langs = [l.strip() for l in args.lang.split(",") if l.strip()] + post_success = post_to_bsky( + client, text=args.text, langs=langs, image_path=args.image, @@ -332,7 +431,8 @@ def main(): alt_text=args.alt, ) - sys.exit(0 if success else 1) + if not post_success: + sys.exit(1) if __name__ == "__main__":