Added some login fixes

This commit is contained in:
Guillem Hernandez Sola
2026-04-18 10:50:47 +02:00
parent 4a83d526f9
commit bdeec32a25
2 changed files with 322 additions and 68 deletions

View File

@@ -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()