Add retry intervals and optional proxies

This commit is contained in:
2026-01-06 00:18:53 -05:00
parent d45e3ca2fa
commit b000f47047
4 changed files with 179 additions and 15 deletions
+7
View File
@@ -3,3 +3,10 @@ VALID_API_KEY=
ROBLOSECURITY_TOKEN= ROBLOSECURITY_TOKEN=
PUBLISHER_USER_ID= PUBLISHER_USER_ID=
DISCORD_WEBHOOK_URL= 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
+55
View File
@@ -17,6 +17,19 @@ def init_db():
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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() conn.commit()
def get_uploaded_asset(image_hash: str) -> Optional[int]: 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() 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 # Initialize the DB on module import
init_db() init_db()
+73 -2
View File
@@ -19,7 +19,61 @@ if TARGET is None:
if not VALID_API_KEY: if not VALID_API_KEY:
raise EnvironmentError("VALID_API_KEY is missing from environment.") 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") 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 # Save to database
database.save_uploaded_asset(image_hash, asset_id, new_asset_id) database.save_uploaded_asset(image_hash, asset_id, new_asset_id)
try:
onsale = await roblox_service.onsale_asset( onsale = await roblox_service.onsale_asset(
new_asset_id, new_asset_id,
asset.name, asset.name,
new_description, new_description,
int(TARGET), 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" asset_type_name = "Shirt" if asset.asset_type == models.RbxAssetType.SHIRT else "Pants"
await discord.send_upload_webhook( await discord.send_upload_webhook(
asset.name, asset_id, new_asset_id, asset_type_name asset.name, asset_id, new_asset_id, asset_type_name
+38 -7
View File
@@ -11,7 +11,7 @@ import models
load_dotenv() load_dotenv()
# Constants # Constants and State
ROBLOSECURITY = os.getenv("ROBLOSECURITY_TOKEN") ROBLOSECURITY = os.getenv("ROBLOSECURITY_TOKEN")
@@ -38,13 +38,35 @@ CSRF_HEADERS = {
CSRF_COOKIES = {".ROBLOSECURITY": ROBLOSECURITY} 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 instance for connection pooling
_client = None _client = None
@@ -200,6 +222,10 @@ async def upload_clothing_image(
}, },
cookies=CSRF_COOKIES, cookies=CSRF_COOKIES,
) )
if response.status_code == 429:
raise RateLimitError("Rate limit hit during upload")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -210,7 +236,7 @@ async def upload_clothing_image(
for attempt in range(max_tries): for attempt in range(max_tries):
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
op_response = await client.get( 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}, headers={"X-CSRF-TOKEN": csrf},
cookies=CSRF_COOKIES, cookies=CSRF_COOKIES,
) )
@@ -254,7 +280,7 @@ async def onsale_asset(
} }
client = _get_client() client = _get_client()
response = await client.post( response = await client.post(
"https://itemconfiguration.roblox.com/v1/collectibles", _proxy_url("https://itemconfiguration.roblox.com/v1/collectibles"),
json=data, json=data,
headers={ headers={
"X-CSRF-TOKEN": csrf, "X-CSRF-TOKEN": csrf,
@@ -264,4 +290,9 @@ async def onsale_asset(
}, },
cookies=CSRF_COOKIES, cookies=CSRF_COOKIES,
) )
if response.status_code == 429:
raise RateLimitError("Rate limit hit during onsale")
response.raise_for_status() response.raise_for_status()
return response.json()