diff --git a/foodie_utils.py b/foodie_utils.py index 9affc2c..c4a71b3 100644 --- a/foodie_utils.py +++ b/foodie_utils.py @@ -1204,10 +1204,9 @@ def get_next_author_round_robin(): def get_x_rate_limit_status(author): """ Check the X API Free tier rate limit by posting a test tweet. - Returns (remaining, reset) based on app-level headers (x-rate-limit-remaining, x-rate-limit-reset). + Returns (remaining, reset) based on app-level or user-level 24-hour headers. Returns (None, None) if the check fails. """ - logger = logging.getLogger(__name__) username = author['username'] credentials = X_API_CREDENTIALS.get(username) if not credentials: @@ -1223,18 +1222,40 @@ def get_x_rate_limit_status(author): url = 'https://api.x.com/2/tweets' payload = {'text': f'Test tweet to check rate limits for {username} - please ignore {int(time.time())}'} + # Add delay to avoid IP-based rate limiting + logger.info(f"Waiting 5 seconds before attempting to post for {username}") + time.sleep(5) + try: response = requests.post(url, json=payload, auth=oauth) headers = response.headers logger.debug(f"Rate limit headers for {username}: {headers}") - # Extract app-level rate limit info from headers - remaining_str = headers.get('x-rate-limit-remaining') - reset_str = headers.get('x-rate-limit-reset') - if remaining_str is None or reset_str is None: - logger.error(f"Rate limit headers missing for {username}: {headers}") + # Initialize defaults + remaining = None + reset = None + current_time = int(time.time()) + + if response.status_code == 201: + # Extract app-level 24-hour limits + remaining_str = headers.get('x-app-limit-24hour-remaining') + reset_str = headers.get('x-app-limit-24hour-reset') + if remaining_str is None or reset_str is None: + logger.error(f"App 24-hour limit headers missing for {username}: {headers}") + return None, None + elif response.status_code == 429: + # Extract user-level 24-hour limits for rate limit exceeded + remaining_str = headers.get('x-user-limit-24hour-remaining') + reset_str = headers.get('x-user-limit-24hour-reset') + if remaining_str is None or reset_str is None: + logger.error(f"User 24-hour limit headers missing for {username}: {headers}") + return None, None + logger.info(f"Rate limit exceeded for {username}") + else: + logger.error(f"Unexpected response for {username}: {response.status_code} - {response.text}") return None, None + # Parse headers try: remaining = int(remaining_str) reset = int(reset_str) @@ -1248,7 +1269,6 @@ def get_x_rate_limit_status(author): remaining = min(remaining, 17) # Ensure reset is in the future - current_time = int(time.time()) if reset <= current_time or reset > current_time + 2 * 86400: # Allow up to 48 hours logger.warning(f"Invalid reset time {reset} ({datetime.fromtimestamp(reset, tz=timezone.utc)}) for {username}. Setting to 24 hours from now.") reset = current_time + 86400 # 24 hours @@ -1263,41 +1283,33 @@ def get_x_rate_limit_status(author): logger.info(f"Successfully deleted test tweet {tweet_id} for {username}") else: logger.warning(f"Failed to delete test tweet {tweet_id} for {username}: {delete_response.status_code} - {delete_response.text}") - logger.info(f"Rate limit for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}") - return remaining, reset - elif response.status_code == 429: - logger.info(f"Rate limit exceeded for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}") - return remaining, reset - elif response.status_code == 403: - logger.warning(f"403 Forbidden for {username}: {response.text}, rate limit info: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}") - return remaining, reset - else: - logger.error(f"Unexpected response for {username}: {response.status_code} - {response.text}") - return None, None + + logger.info(f"Rate limit for {username}: {remaining} remaining, reset at {datetime.fromtimestamp(reset, tz=timezone.utc)}") + return remaining, reset except Exception as e: logger.error(f"Unexpected error fetching X rate limit for {username}: {e}", exc_info=True) return None, None + def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400): """ - Check if an author can post based on their X API Free tier quota (17 tweets per 24 hours per app). + Check if an author can post based on their X API Free tier quota (17 tweets per 24 hours per user). Posts a test tweet only on script restart or for new authors, then tracks tweets in rate_limit_info.json. Returns (can_post, remaining, reset_timestamp) where can_post is True if tweets are available. """ - logger = logging.getLogger(__name__) rate_limit_file = '/home/shane/foodie_automator/rate_limit_info.json' current_time = time.time() - + # Load rate limit info rate_limit_info = load_json_file(rate_limit_file, default={}) - + # Get script run ID if not hasattr(check_author_rate_limit, "script_run_id"): check_author_rate_limit.script_run_id = int(current_time) logger.info(f"Set script_run_id to {check_author_rate_limit.script_run_id}") - + username = author['username'] - + # Initialize or update author entry if username not in rate_limit_info: rate_limit_info[username] = { @@ -1306,10 +1318,10 @@ def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400): 'tweets_posted_in_run': 0, 'script_run_id': 0 # Force test tweet for new authors } - + author_info = rate_limit_info[username] script_run_id = author_info.get('script_run_id', 0) - + # Check if quota has reset based on previous reset time reset = author_info.get('tweet_reset', current_time + tweet_window_seconds) if current_time >= reset: @@ -1320,7 +1332,7 @@ def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400): author_info['script_run_id'] = check_author_rate_limit.script_run_id rate_limit_info[username] = author_info save_json_file(rate_limit_file, rate_limit_info) - + # If script restarted or new author, post a test tweet to sync quota if script_run_id != check_author_rate_limit.script_run_id: logger.info(f"Script restart detected for {username}, posting test tweet to sync quota") @@ -1336,35 +1348,27 @@ def check_author_rate_limit(author, max_tweets=17, tweet_window_seconds=86400): reset = current_time + tweet_window_seconds logger.warning(f"Test tweet failed for {username}, resetting quota to {max_tweets}") else: - # Only update remaining if the API reset time is newer and valid - if api_reset > author_info.get('tweet_reset', 0) and api_reset > current_time: - logger.info(f"Updating quota for {username} from API: {remaining} remaining, reset at {datetime.fromtimestamp(api_reset, tz=timezone.utc)}") - author_info['tweet_remaining'] = remaining - author_info['tweet_reset'] = api_reset - else: - logger.info(f"Retaining previous quota for {username}: {author_info['tweet_remaining']} remaining") - remaining = author_info['tweet_remaining'] - # Keep the existing reset time (from quota reset or previous state) - reset = author_info['tweet_reset'] - + remaining = min(remaining, max_tweets) # Ensure within Free tier limit + reset = api_reset + # Update author info - author_info['tweets_posted_in_run'] = 0 - author_info['script_run_id'] = check_author_rate_limit.script_run_id author_info['tweet_remaining'] = remaining author_info['tweet_reset'] = reset + author_info['tweets_posted_in_run'] = 0 + author_info['script_run_id'] = check_author_rate_limit.script_run_id rate_limit_info[username] = author_info save_json_file(rate_limit_file, rate_limit_info) - + # Calculate remaining tweets remaining = author_info['tweet_remaining'] - author_info['tweets_posted_in_run'] - + can_post = remaining > 0 if not can_post: reset_time = datetime.fromtimestamp(reset, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S') logger.info(f"Author {username} quota exhausted. Remaining: {remaining}, Reset at: {reset_time}") else: logger.info(f"Quota for {username}: {remaining}/{max_tweets} tweets remaining") - + return can_post, remaining, reset def prepare_post_data(summary, title, main_topic=None):