Add retry intervals and optional proxies
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+73
-2
@@ -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)
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user