New login hardening
This commit is contained in:
@@ -11,6 +11,7 @@ import time
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
|
import random
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from atproto import Client, client_utils, models
|
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_BASE_DELAY = 5
|
||||||
BSKY_SEND_POST_MAX_DELAY = 60
|
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
|
MEDIA_DOWNLOAD_TIMEOUT = 30
|
||||||
LINK_METADATA_TIMEOUT = 10
|
LINK_METADATA_TIMEOUT = 10
|
||||||
URL_RESOLVE_TIMEOUT = 12
|
URL_RESOLVE_TIMEOUT = 12
|
||||||
@@ -1336,30 +1343,113 @@ def build_text_media_key(normalized_text, media_fingerprint):
|
|||||||
).hexdigest()
|
).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):
|
def create_bsky_client(base_url, handle, password):
|
||||||
normalized_base_url = (base_url or DEFAULT_BSKY_BASE_URL).strip().rstrip("/")
|
normalized_base_url = (base_url or DEFAULT_BSKY_BASE_URL).strip().rstrip("/")
|
||||||
logging.info(f"🔐 Connecting Bluesky client via base URL: {normalized_base_url}")
|
logging.info(f"🔐 Connecting Bluesky client via base URL: {normalized_base_url}")
|
||||||
|
|
||||||
client = Client(base_url=normalized_base_url)
|
client = Client(base_url=normalized_base_url)
|
||||||
|
|
||||||
max_retries = 3
|
max_attempts = BSKY_LOGIN_MAX_RETRIES
|
||||||
for attempt in range(1, max_retries + 1):
|
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:
|
try:
|
||||||
|
logging.info(f"🔐 Bluesky login attempt {attempt}/{max_attempts} for {handle}")
|
||||||
client.login(handle, password)
|
client.login(handle, password)
|
||||||
|
logging.info("✅ Bluesky login successful.")
|
||||||
return client
|
return client
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = str(e)
|
logging.exception("❌ Bluesky login exception")
|
||||||
is_rate = ("429" in msg) or ("RateLimitExceeded" in msg)
|
|
||||||
if is_rate and attempt < max_retries:
|
# Fail fast on invalid credentials
|
||||||
wait = get_rate_limit_wait_seconds(e, default_delay=60)
|
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(
|
logging.warning(
|
||||||
f"⏳ Login rate-limited (attempt {attempt}/{max_retries}). "
|
f"⏳ Bluesky login rate-limited (attempt {attempt}/{max_attempts}). "
|
||||||
f"Sleeping {wait}s before retry."
|
f"Retrying in {wait:.1f}s."
|
||||||
)
|
)
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
logging.error("❌ Exhausted Bluesky login retries due to rate limiting.")
|
||||||
raise
|
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"⏳ 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 ---
|
# --- State Management ---
|
||||||
def default_state():
|
def default_state():
|
||||||
@@ -1575,20 +1665,70 @@ def get_recent_bsky_posts(client, handle, limit=30):
|
|||||||
|
|
||||||
# --- Upload / Retry Helpers ---
|
# --- Upload / Retry Helpers ---
|
||||||
def get_rate_limit_wait_seconds(error_obj, default_delay):
|
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:
|
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())
|
now_ts = int(time.time())
|
||||||
reset_ts = int(reset_value)
|
|
||||||
wait_seconds = max(reset_ts - now_ts + 1, default_delay)
|
# Direct headers on exception
|
||||||
return min(wait_seconds, BSKY_BLOB_UPLOAD_MAX_DELAY)
|
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:
|
except Exception:
|
||||||
pass
|
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
|
return default_delay
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user