fix(rss): stop posting after Bluesky createRecord rate limit and add clear post start/finish logs

This commit is contained in:
2026-04-10 19:28:28 +00:00
parent 3a4b6ce65e
commit e158f99cf8

View File

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