Added some login fixes
This commit is contained in:
172
rss2bsky.py
172
rss2bsky.py
@@ -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)
|
||||
|
||||
210
testlogin.py
210
testlogin.py
@@ -1,46 +1,200 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from atproto import Client
|
||||
|
||||
# --- Logging ---
|
||||
LOG_PATH = "rss2bsky_test.log"
|
||||
LOG_PATH = "bsky_login_test.log"
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s %(message)s",
|
||||
filename=LOG_PATH,
|
||||
encoding="utf-8",
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_PATH, encoding="utf-8"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
level=logging.INFO,
|
||||
)
|
||||
|
||||
def main():
|
||||
# --- Parse command-line arguments ---
|
||||
parser = argparse.ArgumentParser(description="Post RSS to Bluesky.")
|
||||
parser.add_argument("rss_feed", help="RSS feed URL")
|
||||
parser.add_argument("bsky_handle", help="Bluesky handle")
|
||||
parser.add_argument("bsky_username", help="Bluesky username")
|
||||
parser.add_argument("bsky_app_password", help="Bluesky app password")
|
||||
parser.add_argument("--service", default="https://bsky.social", help="Bluesky server URL (default: https://bsky.social)")
|
||||
EXIT_OK = 0
|
||||
EXIT_BAD_CREDS = 2
|
||||
EXIT_RATE_LIMIT = 3
|
||||
EXIT_NETWORK = 4
|
||||
EXIT_OTHER = 5
|
||||
|
||||
args = parser.parse_args()
|
||||
bsky_username = args.bsky_username
|
||||
bsky_password = args.bsky_app_password
|
||||
service_url = args.service
|
||||
|
||||
# --- Login ---
|
||||
# SOLUCIÓ: Passem el base_url directament al constructor del Client
|
||||
client = Client(base_url=service_url)
|
||||
def parse_wait_seconds_from_exception(exc, default_delay=15, max_delay=900):
|
||||
"""
|
||||
Parse common rate-limit headers from atproto exceptions:
|
||||
- retry-after (seconds)
|
||||
- x-ratelimit-after (seconds)
|
||||
- ratelimit-reset (unix timestamp)
|
||||
"""
|
||||
try:
|
||||
headers = getattr(exc, "headers", None) or {}
|
||||
|
||||
backoff = 60
|
||||
while True:
|
||||
retry_after = headers.get("retry-after") or headers.get("Retry-After")
|
||||
if retry_after:
|
||||
return min(max(int(retry_after), 1), 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), max_delay)
|
||||
|
||||
reset = headers.get("ratelimit-reset") or headers.get("RateLimit-Reset")
|
||||
if reset:
|
||||
wait_s = max(int(reset) - int(time.time()) + 1, 1)
|
||||
return min(wait_s, max_delay)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return default_delay
|
||||
|
||||
|
||||
def classify_error(exc):
|
||||
"""
|
||||
Classify exception into:
|
||||
- rate_limit
|
||||
- bad_creds
|
||||
- network
|
||||
- other
|
||||
"""
|
||||
text = repr(exc).lower()
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
|
||||
if status_code == 429 or "429" in text or "too many requests" in text or "ratelimit" in text:
|
||||
return "rate_limit"
|
||||
|
||||
if status_code in (401, 403) or "invalid identifier or password" in text or "authentication" in text:
|
||||
return "bad_creds"
|
||||
|
||||
transient_signals = [
|
||||
"timeout",
|
||||
"connecterror",
|
||||
"remoteprotocolerror",
|
||||
"readtimeout",
|
||||
"writetimeout",
|
||||
"503",
|
||||
"502",
|
||||
"504",
|
||||
"connection",
|
||||
]
|
||||
if any(sig in text for sig in transient_signals):
|
||||
return "network"
|
||||
|
||||
return "other"
|
||||
|
||||
|
||||
def preflight_health(service_url, timeout=8):
|
||||
url = f"{service_url.rstrip('/')}/xrpc/_health"
|
||||
try:
|
||||
r = httpx.get(url, timeout=timeout)
|
||||
logging.info(f"🩺 Health check {url} -> HTTP {r.status_code}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.warning(f"🩺 Health check failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def build_client(service_url):
|
||||
normalized = service_url.strip().rstrip("/")
|
||||
|
||||
try:
|
||||
return Client(base_url=normalized)
|
||||
except TypeError:
|
||||
logging.warning("⚠️ Client(base_url=...) unsupported in this atproto version. Falling back.")
|
||||
c = Client()
|
||||
try:
|
||||
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
|
||||
if hasattr(c, "base_url"):
|
||||
c.base_url = normalized
|
||||
elif hasattr(c, "_base_url"):
|
||||
c._base_url = normalized
|
||||
except Exception as e:
|
||||
logging.exception("Login exception")
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff + 60, 600)
|
||||
logging.warning(f"⚠️ Could not apply custom base URL: {e}")
|
||||
return c
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bluesky login test only.")
|
||||
parser.add_argument("--bsky-handle", required=True, help="Bluesky handle (e.g. user.example.social)")
|
||||
parser.add_argument(
|
||||
"--bsky-app-password",
|
||||
default=None,
|
||||
help="Bluesky app password (prefer env BSKY_APP_PASSWORD)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--service",
|
||||
default="https://bsky.social",
|
||||
help="PDS base URL (default: https://bsky.social)",
|
||||
)
|
||||
parser.add_argument("--max-attempts", type=int, default=3, help="Retry attempts (default: 3)")
|
||||
parser.add_argument("--base-delay", type=int, default=10, help="Base retry delay in seconds (default: 10)")
|
||||
parser.add_argument("--jitter-max", type=float, default=2.0, help="Random jitter max seconds (default: 2.0)")
|
||||
args = parser.parse_args()
|
||||
|
||||
handle = args.bsky_handle.strip()
|
||||
service_url = args.service.strip().rstrip("/")
|
||||
app_password = (args.bsky_app_password or os.getenv("BSKY_APP_PASSWORD", "")).strip()
|
||||
|
||||
if not app_password:
|
||||
logging.error("❌ Missing app password. Use --bsky-app-password or env BSKY_APP_PASSWORD.")
|
||||
print("LOGIN_FAILED_BAD_CREDS")
|
||||
sys.exit(EXIT_BAD_CREDS)
|
||||
|
||||
logging.info(f"🔐 Testing login against: {service_url}")
|
||||
logging.info(f"👤 Handle: {handle}")
|
||||
|
||||
# Optional but useful diagnostics
|
||||
preflight_health(service_url)
|
||||
|
||||
client = build_client(service_url)
|
||||
|
||||
last_kind = "other"
|
||||
|
||||
for attempt in range(1, args.max_attempts + 1):
|
||||
try:
|
||||
logging.info(f"➡️ Login attempt {attempt}/{args.max_attempts}")
|
||||
client.login(handle, app_password)
|
||||
logging.info("✅ Login successful.")
|
||||
print("LOGIN_OK")
|
||||
sys.exit(EXIT_OK)
|
||||
|
||||
except Exception as e:
|
||||
last_kind = classify_error(e)
|
||||
logging.exception(f"❌ Login failed [{last_kind}]")
|
||||
|
||||
if last_kind == "bad_creds":
|
||||
print("LOGIN_FAILED_BAD_CREDS")
|
||||
sys.exit(EXIT_BAD_CREDS)
|
||||
|
||||
if attempt >= args.max_attempts:
|
||||
break
|
||||
|
||||
if last_kind == "rate_limit":
|
||||
wait_s = parse_wait_seconds_from_exception(e, default_delay=args.base_delay)
|
||||
elif last_kind == "network":
|
||||
wait_s = min(args.base_delay * attempt, 60)
|
||||
else:
|
||||
wait_s = min(args.base_delay * attempt, 45)
|
||||
|
||||
wait_s = wait_s + random.uniform(0, max(args.jitter_max, 0.0))
|
||||
logging.warning(f"⏳ Waiting {wait_s:.1f}s before retry...")
|
||||
time.sleep(wait_s)
|
||||
|
||||
if last_kind == "rate_limit":
|
||||
print("LOGIN_FAILED_RATE_LIMIT")
|
||||
sys.exit(EXIT_RATE_LIMIT)
|
||||
if last_kind == "network":
|
||||
print("LOGIN_FAILED_NETWORK")
|
||||
sys.exit(EXIT_NETWORK)
|
||||
|
||||
print("LOGIN_FAILED")
|
||||
sys.exit(EXIT_OTHER)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user