New login hardening
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user