DEV Community

yulingg zhang
yulingg zhang

Posted on Fully Autonomous

A timeout is not a failed job: handling daily quotas for an AI image generator

An image-generation request can disappear from the browser while the provider is still working. That makes a daily allowance a small accounting problem, not just a counter on a button.

This note comes from the quota implementation in RetroPrompt, a portrait project I maintain. The implementation uses a Cloudflare Worker and D1. The important distinction is between a confirmed failure and an outcome we cannot yet confirm.

Three states, one invariant

Each attempt has an ID, an account or anonymous-browser identifier, a UTC day, and a status: pending, success, or failed. The remaining allowance is:

remaining = max(0, daily_limit - successful_attempts - pending_attempts)
Enter fullscreen mode Exit fullscreen mode

A pending request reserves capacity before the provider call starts. A confirmed successful result consumes it. A confirmed failure stops counting against it.

Do not implement reservation as a separate SELECT followed by an unconditional INSERT. Two concurrent requests could both see the same remaining slot. In this project's SQLite-backed implementation, the count check and conditional insert are one statement. A simplified version is:

INSERT INTO attempts (id, account_id, utc_day, status)
SELECT ?, ?, ?, 'pending'
WHERE (
  SELECT COUNT(*) FROM attempts
  WHERE account_id = ? AND utc_day = ?
    AND status IN ('pending', 'success')
) < ?;
Enter fullscreen mode Exit fullscreen mode

Check whether the insert actually wrote a row before starting expensive work. This is an example for a single SQLite database, not a claim that an arbitrary distributed database offers the same concurrency guarantees.

The bug was in the failure branch

The provider returns an asynchronous task ID. The Worker polls that task until it completes, fails, or stops being observable within the request window.

An earlier version threw an exception when polling returned an explicit failed status. The general exception handler then treated it like a timeout and left the attempt pending. The user lost access to a slot even though the provider had already confirmed failure.

The fix is to handle that terminal status explicitly: update the attempt from pending to failed, return a clear rejection message, and recompute the allowance. Keep the update conditional on the previous status so an already finalized attempt is not accidentally rewritten.

Unknown is a different outcome

A network timeout after submitting the task does not prove that generation failed. Neither does losing the response while retrieving the completed image. Automatically releasing a slot in these cases can let retries start additional paid jobs.

The current conservative behavior keeps uncertain attempts pending until the next UTC allowance window. That protects the small daily budget, but it is a real user-experience limitation: someone may have to wait even when no image reached their browser. It is not a refund system or an exactly-once guarantee.

A stronger future design would persist provider task IDs and reconcile pending attempts asynchronously. Provider-supported idempotency keys could also help, if their documented behavior matches the retry strategy. Those are improvements to investigate, not features this implementation already guarantees.

Tests that distinguish these cases

A useful regression suite should exercise behavior rather than just check the displayed counter:

  • A provider task explicitly fails: the reserved slot becomes available again.
  • The submit or polling request times out: the reservation remains.
  • Two requests compete for the last slot: only one reservation succeeds.
  • A result completes: the attempt moves to success, with no double counting.
  • The UTC day changes: the new allowance uses the new day bucket.

Fixture-based provider tests can verify these transitions without spending image credits. They do not establish current provider uptime or output quality. Our recent regression work used that separation.

There is another boundary: an anonymous browser cookie is not a verified person. Clearing it or switching browsers can create a new allowance identity. Strong abuse prevention requires a separate design; the daily counter alone does not provide it.

Project context: RetroPrompt. I maintain the linked project. This article was prepared with AI assistance and checked against its implementation; it reports no traffic or performance benchmark.

Top comments (0)