From e158f99cf888c07b1ee7d34740102cbd1e90225a Mon Sep 17 00:00:00 2001 From: Guillem Hernandez Sola Date: Fri, 10 Apr 2026 19:28:28 +0000 Subject: [PATCH] fix(rss): stop posting after Bluesky createRecord rate limit and add clear post start/finish logs --- rss2bsky.py | 72 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/rss2bsky.py b/rss2bsky.py index 99c44c2..0cf6356 100644 --- a/rss2bsky.py +++ b/rss2bsky.py @@ -41,8 +41,12 @@ BSKY_BLOB_TRANSIENT_ERROR_DELAY = 15 HTTP_TIMEOUT = 20 POST_RETRY_DELAY_SECONDS = 2 +# Thumbnail upload cooldown state THUMB_UPLOAD_COOLDOWN_UNTIL = 0 +# Post creation cooldown state +POST_CREATION_COOLDOWN_UNTIL = 0 + # --- Logging --- logging.basicConfig( format="%(asctime)s %(message)s", @@ -83,7 +87,8 @@ def strip_trailing_url_punctuation(url): def canonicalize_url(url): if not url: return None - return strip_trailing_url_punctuation(url.strip()) + url = html.unescape(url.strip()) + return strip_trailing_url_punctuation(url) def clean_whitespace(text): @@ -137,12 +142,9 @@ def build_post_text_variants(title_text, link): 1. Full title + blank line + real URL Fallbacks: 2. Full title only - 3. Truncated title only Intentionally avoid: - truncated title + URL - - That pattern creates ugly posts with "..." before the visible link. """ title_text = clean_whitespace(title_text) link = canonicalize_url(link) or link or "" @@ -161,7 +163,6 @@ def build_post_text_variants(title_text, link): if title_text: add_variant(title_text) - add_variant(truncate_text_safely(title_text)) if link and not title_text: add_variant(link) @@ -454,7 +455,7 @@ def make_rich(content): return text_builder -# --- Blob / image upload helpers --- +# --- Rate limit helpers --- def get_rate_limit_wait_seconds(error_obj, default_delay): try: headers = getattr(error_obj, "headers", None) @@ -483,6 +484,11 @@ def get_rate_limit_reset_timestamp(error_obj): return None +def is_rate_limited_error(error_obj): + error_text = str(error_obj) + return "429" in error_text or "RateLimitExceeded" in error_text + + def is_transient_blob_error(error_obj): error_text = repr(error_obj) transient_signals = [ @@ -499,11 +505,30 @@ def is_transient_blob_error(error_obj): return any(signal in error_text for signal in transient_signals) -def is_rate_limited_error(error_obj): - error_text = str(error_obj) - return "429" in error_text or "RateLimitExceeded" in error_text +# --- Post cooldown helpers --- +def activate_post_creation_cooldown_from_error(error_obj): + global POST_CREATION_COOLDOWN_UNTIL + + reset_ts = get_rate_limit_reset_timestamp(error_obj) + if reset_ts: + if reset_ts > POST_CREATION_COOLDOWN_UNTIL: + POST_CREATION_COOLDOWN_UNTIL = reset_ts + logging.error( + f"=== BSKY POST STOPPED: RATE LIMITED === Posting disabled until " + f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(reset_ts))}" + ) + else: + fallback_reset = int(time.time()) + 3600 + if fallback_reset > POST_CREATION_COOLDOWN_UNTIL: + POST_CREATION_COOLDOWN_UNTIL = fallback_reset + logging.error("=== BSKY POST STOPPED: RATE LIMITED === Posting disabled for 1 hour.") +def is_post_creation_cooldown_active(): + return int(time.time()) < POST_CREATION_COOLDOWN_UNTIL + + +# --- Thumbnail cooldown helpers --- def activate_thumb_upload_cooldown_from_error(error_obj): global THUMB_UPLOAD_COOLDOWN_UNTIL @@ -526,6 +551,7 @@ def is_thumb_upload_cooldown_active(): return int(time.time()) < THUMB_UPLOAD_COOLDOWN_UNTIL +# --- Blob / image upload helpers --- def upload_blob_with_retry(client, binary_data, media_label="media", optional=False, cooldown_on_rate_limit=False): last_exception = None transient_attempts = 0 @@ -787,6 +813,12 @@ def is_probable_length_error(exc): def try_send_post_with_variants(client, text_variants, embed, post_lang): + global POST_CREATION_COOLDOWN_UNTIL + + if is_post_creation_cooldown_active(): + reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(POST_CREATION_COOLDOWN_UNTIL)) + raise RuntimeError(f"Posting skipped because post creation cooldown is active until {reset_str}") + last_exception = None for idx, variant in enumerate(text_variants, start=1): @@ -800,6 +832,10 @@ def try_send_post_with_variants(client, text_variants, embed, post_lang): last_exception = e logging.warning(f"Post variant {idx} failed: {repr(e)}") + if is_rate_limited_error(e): + activate_post_creation_cooldown_from_error(e) + raise + if not is_probable_length_error(e): raise @@ -894,11 +930,17 @@ def main(): with httpx.Client() as http_client: for candidate in entries_to_post: + if is_post_creation_cooldown_active(): + reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(POST_CREATION_COOLDOWN_UNTIL)) + logging.error(f"=== BSKY POST STOPPED: RATE LIMITED === Skipping remaining entries until {reset_str}") + break + title_text = candidate["title_text"] canonical_link = candidate["canonical_link"] text_variants = candidate["post_text_variants"] logging.info(f"Preparing to post RSS entry: {canonical_link or title_text}") + logging.info(f"=== BSKY POST START === {canonical_link or title_text}") embed = None if canonical_link: @@ -933,11 +975,17 @@ def main(): recent_bsky_posts = recent_bsky_posts[:DEDUPE_BSKY_LIMIT] noves_entrades += 1 + logging.info(f"=== BSKY POST SUCCESS === {canonical_link or title_text}") logging.info(f"Posted RSS entry to Bluesky: {canonical_link or title_text}") time.sleep(POST_RETRY_DELAY_SECONDS) - except Exception: - logging.exception(f"Failed to post RSS entry {canonical_link or title_text}") + except Exception as e: + logging.exception(f"=== BSKY POST FAILED === {canonical_link or title_text}") + + if is_rate_limited_error(e) or is_post_creation_cooldown_active(): + reset_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(POST_CREATION_COOLDOWN_UNTIL)) + logging.error(f"=== BSKY POST STOPPED: RATE LIMITED === Ending publish loop until {reset_str}") + break if noves_entrades > 0: logging.info(f"🎉 Execution finished: published {noves_entrades} new entries to Bluesky.") @@ -946,4 +994,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main()