feat: harden Bluesky login with robust retry and error handling
- Ported advanced error detection (`is_auth_error`, `is_transient_error`, `is_network_error`) from the daemon script to accurately classify exceptions. - Implemented `get_rate_limit_wait_seconds` to intelligently parse HTTP rate-limit headers (`retry-after`, `ratelimit-reset`) instead of relying on static backoffs. - Increased default login retry attempts to 5, with jitter and bounded delays. - Updated `login_with_backoff` to fail fast on invalid credentials while gracefully handling transient network issues and rate limits.
This commit is contained in:
240
bsky_post.py
240
bsky_post.py
@@ -16,6 +16,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from atproto import Client, client_utils, models
|
from atproto import Client, client_utils, models
|
||||||
@@ -27,7 +28,7 @@ from atproto import Client, client_utils, models
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RetryConfig:
|
class RetryConfig:
|
||||||
login_max_attempts: int = 5
|
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_max_delay_seconds: float = 600.0
|
||||||
login_jitter_seconds: float = 1.5
|
login_jitter_seconds: float = 1.5
|
||||||
|
|
||||||
@@ -87,34 +88,125 @@ def make_rich(content: str):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Error helpers
|
# Error helpers
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def is_rate_limited_error(e) -> bool:
|
def is_rate_limited_error(error_obj) -> bool:
|
||||||
text = repr(e)
|
text = repr(error_obj).lower()
|
||||||
return any(s in text for s in ["429", "RateLimitExceeded", "Too Many Requests"])
|
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:
|
def is_auth_error(error_obj) -> bool:
|
||||||
text = repr(e).lower()
|
text = repr(error_obj).lower()
|
||||||
return any(s in text for s in [
|
return (
|
||||||
"401", "403", "invalid identifier or password",
|
"401" in text
|
||||||
"authenticationrequired", "invalidtoken",
|
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:
|
def is_network_error(error_obj) -> bool:
|
||||||
text = repr(e)
|
text = repr(error_obj)
|
||||||
return any(s in text for s in [
|
signals = [
|
||||||
"ConnectError", "RemoteProtocolError", "ReadTimeout",
|
"ConnectError",
|
||||||
"WriteTimeout", "TimeoutException", "503", "502", "504",
|
"RemoteProtocolError",
|
||||||
|
"ReadTimeout",
|
||||||
|
"WriteTimeout",
|
||||||
|
"TimeoutException",
|
||||||
|
"503",
|
||||||
|
"502",
|
||||||
|
"504",
|
||||||
"ConnectionResetError",
|
"ConnectionResetError",
|
||||||
])
|
]
|
||||||
|
return any(sig in text for sig in signals)
|
||||||
|
|
||||||
|
|
||||||
def is_timeout_error(e) -> bool:
|
def is_transient_error(error_obj) -> bool:
|
||||||
text = repr(e)
|
error_text = repr(error_obj)
|
||||||
return any(s in text for s in [
|
transient_signals = [
|
||||||
"InvokeTimeoutError", "ReadTimeout",
|
"InvokeTimeoutError",
|
||||||
"WriteTimeout", "TimeoutException",
|
"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,
|
password: str,
|
||||||
service_url: str,
|
service_url: str,
|
||||||
max_attempts: int = 5,
|
max_attempts: int = 5,
|
||||||
base_delay: float = 2.0,
|
base_delay: float = 10.0,
|
||||||
max_delay: float = 600.0,
|
max_delay: float = 600.0,
|
||||||
jitter: float = 1.5,
|
jitter: float = 1.5,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
try:
|
try:
|
||||||
logging.info(
|
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)
|
client.login(username, password)
|
||||||
logging.info("✅ Login successful.")
|
logging.info("✅ Login successful.")
|
||||||
@@ -142,25 +234,49 @@ def login_with_backoff(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception("❌ Login exception")
|
logging.exception("❌ Login exception")
|
||||||
|
|
||||||
|
# Fail fast on invalid credentials
|
||||||
if is_auth_error(e):
|
if is_auth_error(e):
|
||||||
logging.error("❌ Bad credentials. Check handle/password.")
|
logging.error("❌ Bad credentials. Check handle/password.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if attempt >= max_attempts:
|
# Respect explicit rate-limit timing
|
||||||
logging.error("❌ Login retries exhausted.")
|
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
|
return False
|
||||||
|
|
||||||
if is_rate_limited_error(e):
|
# Retry transient/network problems
|
||||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay) + random.uniform(0, jitter)
|
if is_network_error(e) or is_transient_error(e):
|
||||||
logging.warning(f"⏳ Rate-limited on login. Retrying in {delay:.1f}s...")
|
if attempt < max_attempts:
|
||||||
elif is_network_error(e) or is_timeout_error(e):
|
wait = min(base_delay * attempt, max_delay) + random.uniform(0, jitter)
|
||||||
delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter)
|
logging.warning(
|
||||||
logging.warning(f"⏳ Transient login error. Retrying in {delay:.1f}s...")
|
f"⏳ Transient login error (attempt {attempt}/{max_attempts}). "
|
||||||
else:
|
f"Retrying in {wait:.1f}s..."
|
||||||
delay = min(base_delay * attempt, max_delay) + random.uniform(0, jitter)
|
)
|
||||||
logging.warning(f"⏳ Unknown login error. Retrying in {delay:.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
|
return False
|
||||||
|
|
||||||
@@ -283,48 +399,31 @@ def main():
|
|||||||
parser.add_argument("text", help="Post text content")
|
parser.add_argument("text", help="Post text content")
|
||||||
parser.add_argument("--username", required=True, help="Bluesky handle or email")
|
parser.add_argument("--username", required=True, help="Bluesky handle or email")
|
||||||
parser.add_argument("--password", required=True, help="Bluesky app password")
|
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("--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("--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("--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()
|
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)
|
client = Client(base_url=args.service)
|
||||||
|
success = login_with_backoff(
|
||||||
logged_in = login_with_backoff(
|
client,
|
||||||
client=client,
|
args.username,
|
||||||
username=args.username,
|
args.password,
|
||||||
password=args.password,
|
args.service,
|
||||||
service_url=args.service,
|
max_attempts=RetryConfig.login_max_attempts,
|
||||||
max_attempts=cfg.login_max_attempts,
|
base_delay=RetryConfig.login_base_delay_seconds,
|
||||||
base_delay=cfg.login_base_delay_seconds,
|
max_delay=RetryConfig.login_max_delay_seconds,
|
||||||
max_delay=cfg.login_max_delay_seconds,
|
jitter=RetryConfig.login_jitter_seconds,
|
||||||
jitter=cfg.login_jitter_seconds,
|
|
||||||
)
|
)
|
||||||
|
if not success:
|
||||||
if not logged_in:
|
|
||||||
logging.error("❌ Could not log in. Exiting.")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
success = post_to_bsky(
|
langs = [l.strip() for l in args.lang.split(",") if l.strip()]
|
||||||
client=client,
|
post_success = post_to_bsky(
|
||||||
|
client,
|
||||||
text=args.text,
|
text=args.text,
|
||||||
langs=langs,
|
langs=langs,
|
||||||
image_path=args.image,
|
image_path=args.image,
|
||||||
@@ -332,7 +431,8 @@ def main():
|
|||||||
alt_text=args.alt,
|
alt_text=args.alt,
|
||||||
)
|
)
|
||||||
|
|
||||||
sys.exit(0 if success else 1)
|
if not post_success:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user