Add retry intervals and optional proxies
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+79
-8
@@ -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)
|
||||||
|
|
||||||
onsale = await roblox_service.onsale_asset(
|
try:
|
||||||
new_asset_id,
|
onsale = await roblox_service.onsale_asset(
|
||||||
asset.name,
|
new_asset_id,
|
||||||
new_description,
|
asset.name,
|
||||||
int(TARGET),
|
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"
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user