From b000f470478b650695e1be16b28cf0a54331353d Mon Sep 17 00:00:00 2001 From: filoxenace Date: Tue, 6 Jan 2026 00:18:53 -0500 Subject: [PATCH] Add retry intervals and optional proxies --- .env.example | 7 +++ src/database.py | 55 +++++++++++++++++++++++ src/main.py | 87 +++++++++++++++++++++++++++++++++---- src/utils/roblox_service.py | 45 ++++++++++++++++--- 4 files changed, 179 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 3cc667a..eb6b614 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ VALID_API_KEY= ROBLOSECURITY_TOKEN= PUBLISHER_USER_ID= DISCORD_WEBHOOK_URL= + +# Optional: Proxy for Roblox APIs (e.g., roproxy.com) +ROBLOX_PROXY= + +# Optional: Retry configuration (in seconds) +RETRY_INTERVAL_SECONDS=60 +RETRY_DELAY_SECONDS=300 diff --git a/src/database.py b/src/database.py index d450873..4943ba8 100644 --- a/src/database.py +++ b/src/database.py @@ -17,6 +17,19 @@ def init_db(): created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS onsale_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL, + original_asset_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + group_id INTEGER NOT NULL, + asset_type TEXT NOT NULL, + retry_count INTEGER DEFAULT 0, + next_retry DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) conn.commit() def get_uploaded_asset(image_hash: str) -> Optional[int]: @@ -43,5 +56,47 @@ def save_uploaded_asset(image_hash: str, original_asset_id: int, new_asset_id: i ) conn.commit() +def add_to_onsale_queue(asset_id: int, original_asset_id: int, name: str, description: str, group_id: int, asset_type: str): + """Adds an asset to the onsale retry queue.""" + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO onsale_queue (asset_id, original_asset_id, name, description, group_id, asset_type) VALUES (?, ?, ?, ?, ?, ?)", + (asset_id, original_asset_id, name, description, group_id, asset_type) + ) + conn.commit() + +def get_pending_onsale_items(): + """Retrieves items from the queue that are ready for retry.""" + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM onsale_queue WHERE next_retry <= CURRENT_TIMESTAMP" + ) + return cursor.fetchall() + +def remove_from_onsale_queue(queue_id: int): + """Removes an item from the onsale queue.""" + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM onsale_queue WHERE id = ?", (queue_id,)) + conn.commit() + +def increment_retry_onsale(queue_id: int, delay_seconds: int = 300): + """Increments retry count and sets next retry time.""" + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE onsale_queue + SET retry_count = retry_count + 1, + next_retry = datetime('now', '+' || ? || ' seconds') + WHERE id = ? + """, + (delay_seconds, queue_id) + ) + conn.commit() + # Initialize the DB on module import init_db() diff --git a/src/main.py b/src/main.py index b7a685e..3654c25 100644 --- a/src/main.py +++ b/src/main.py @@ -19,7 +19,61 @@ if TARGET is None: if not VALID_API_KEY: raise EnvironmentError("VALID_API_KEY is missing from environment.") -app = FastAPI() +from contextlib import asynccontextmanager + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Start background task + task = asyncio.create_task(process_onsale_queue()) + yield + # Cleanup + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + +app = FastAPI(lifespan=lifespan) + +# Retry Configuration +RETRY_INTERVAL = int(os.getenv("RETRY_INTERVAL_SECONDS", 60)) +RETRY_DELAY = int(os.getenv("RETRY_DELAY_SECONDS", 300)) + +async def process_onsale_queue(): + """Background task to retry failed onsale attempts.""" + while True: + try: + pending_items = database.get_pending_onsale_items() + for item in pending_items: + print(f"Retrying onsale for asset {item['asset_id']} (Attempt {item['retry_count'] + 1})") + try: + await roblox_service.onsale_asset( + item["asset_id"], + item["name"], + item["description"], + item["group_id"], + ) + database.remove_from_onsale_queue(item["id"]) + print(f"Successfully put asset {item['asset_id']} on sale via queue.") + + # Send Discord notification on successful retry + await discord.send_upload_webhook( + item["name"], + item["original_asset_id"], + item["asset_id"], + item["asset_type"] + ) + except roblox_service.RateLimitError: + print(f"Rate limit hit again for asset {item['asset_id']}, backing off.") + database.increment_retry_onsale(item["id"], delay_seconds=RETRY_DELAY) + except Exception as e: + print(f"Unexpected error retrying asset {item['asset_id']}: {e}") + database.increment_retry_onsale(item["id"], delay_seconds=RETRY_DELAY * 2) + + await asyncio.sleep(RETRY_INTERVAL) + except Exception as e: + print(f"Error in background queue task: {e}") + await asyncio.sleep(RETRY_INTERVAL) api_key_header = APIKeyHeader(name="x-api-key") @@ -85,14 +139,31 @@ async def reupload_asset(asset_id: int, _: str = Depends(verify_api_key)): # Save to database database.save_uploaded_asset(image_hash, asset_id, new_asset_id) - onsale = await roblox_service.onsale_asset( - new_asset_id, - asset.name, - new_description, - int(TARGET), - ) + try: + onsale = await roblox_service.onsale_asset( + new_asset_id, + asset.name, + new_description, + int(TARGET), + ) + except roblox_service.RateLimitError: + print(f"Rate limit hit for asset {new_asset_id}, adding to retry queue.") + asset_type_name = "Shirt" if asset.asset_type == models.RbxAssetType.SHIRT else "Pants" + database.add_to_onsale_queue( + new_asset_id, + asset_id, # original + asset.name, + new_description, + int(TARGET), + asset_type_name + ) + return { + "uploaded": uploaded, + "onsale": "queued", + "info": "Rate limit hit, item will be put on sale automatically later." + } - # Send Discord notification + # Send Discord notification (only for successful initial onsale) asset_type_name = "Shirt" if asset.asset_type == models.RbxAssetType.SHIRT else "Pants" await discord.send_upload_webhook( asset.name, asset_id, new_asset_id, asset_type_name diff --git a/src/utils/roblox_service.py b/src/utils/roblox_service.py index 9bba700..485f4cd 100644 --- a/src/utils/roblox_service.py +++ b/src/utils/roblox_service.py @@ -11,7 +11,7 @@ import models load_dotenv() -# Constants +# Constants and State ROBLOSECURITY = os.getenv("ROBLOSECURITY_TOKEN") @@ -38,13 +38,35 @@ CSRF_HEADERS = { CSRF_COOKIES = {".ROBLOSECURITY": ROBLOSECURITY} -CSRF_URL = "https://apis.roblox.com/assets/user-auth/v1/assets" +# Proxy configuration +ROBLOX_PROXY = os.getenv("ROBLOX_PROXY") -ASSET_DELIVERY_BASE_URL = "https://assetdelivery.roblox.com/v1/asset/?id={asset_id}" -ECONOMY_BASE_URL = "https://economy.roblox.com/v2/assets/{asset_id}/details" +def _proxy_url(url: str) -> str: + """Redirect roblox.com URLs to a proxy if configured.""" + if not ROBLOX_PROXY: + return url + return url.replace("roblox.com", ROBLOX_PROXY) + + +CSRF_URL = _proxy_url("https://apis.roblox.com/assets/user-auth/v1/assets") + +ASSET_DELIVERY_BASE_URL = _proxy_url( + "https://assetdelivery.roblox.com/v1/asset/?id={asset_id}" +) + +ECONOMY_BASE_URL = _proxy_url("https://economy.roblox.com/v2/assets/{asset_id}/details") + +UPLOAD_URL = _proxy_url("https://apis.roblox.com/assets/user-auth/v1/assets") + +# Custom Exceptions + + +class RateLimitError(Exception): + """Raised when hitting Roblox rate limits (HTTP 429).""" + + pass -UPLOAD_URL = "https://apis.roblox.com/assets/user-auth/v1/assets" # Client instance for connection pooling _client = None @@ -200,6 +222,10 @@ async def upload_clothing_image( }, cookies=CSRF_COOKIES, ) + + if response.status_code == 429: + raise RateLimitError("Rate limit hit during upload") + response.raise_for_status() data = response.json() @@ -210,7 +236,7 @@ async def upload_clothing_image( for attempt in range(max_tries): await asyncio.sleep(wait_time) op_response = await client.get( - f"https://apis.roblox.com/assets/user-auth/v1/operations/{operation_id}", + _proxy_url(f"https://apis.roblox.com/assets/user-auth/v1/operations/{operation_id}"), headers={"X-CSRF-TOKEN": csrf}, cookies=CSRF_COOKIES, ) @@ -254,7 +280,7 @@ async def onsale_asset( } client = _get_client() response = await client.post( - "https://itemconfiguration.roblox.com/v1/collectibles", + _proxy_url("https://itemconfiguration.roblox.com/v1/collectibles"), json=data, headers={ "X-CSRF-TOKEN": csrf, @@ -264,4 +290,9 @@ async def onsale_asset( }, cookies=CSRF_COOKIES, ) + + if response.status_code == 429: + raise RateLimitError("Rate limit hit during onsale") + response.raise_for_status() + return response.json()