Handling Timeouts in AI Image Generation Without Blind Retries A developer outlined a state-machine approach for handling timeouts in asynchronous AI image generation APIs, arguing that a lost HTTP response cannot be treated as a confirmed failure because the provider may have already accepted and billed for the task. The design assigns a local request ID before submission, persists its association with the provider's task ID, and uses states such as status_unknown and storage_failed to separate uncertain submissions from confirmed failures. Application credits are reserved before submission and retained through ambiguity, with retries routed to a stored attempt rather than a new provider submission. A user clicks Generate. Your backend submits a request to an image provider, then the HTTP call times out. The provider may already be creating an image. Submitting again could create a second paid task. The code examples are simplified illustrations. An asynchronous generation API still has an initial HTTP exchange: submit a request and receive a task ID. That exchange can fail independently of the work it starts. A documented rejection can establish that the request was not accepted. A timeout or lost response cannot establish the same thing. The request might never have arrived, or the provider might have accepted it before the response was lost. There are two costs to distinguish here. Creating another provider task can incur another upstream charge. Deducting the user's application credits twice is a separate accounting error. A local credit reservation does not prevent duplicate provider tasks, and provider deduplication does not make your local ledger idempotent. The application needs an identity before it calls the provider. A local request ID identifies one intended generation. The provider's task ID identifies the work accepted upstream. Persist their association when a submission response arrives. A request ID alone does not provide idempotency. Reusing it must lead to a stored attempt rather than another submission. The service must also check ownership and whether the request parameters match the original attempt. Reusing an ID for different input should not silently return an unrelated result. In our service, the existing-attempt path checks identity and input consistency. Submission uses persisted state and a lease to coordinate competing requests. The client must retain the ID across recovery; generating a fresh ID for every retry defeats local deduplication. A useful state model separates uncertainty from confirmed failure, and generation from delivery: // Teaching model: names describe local application states. type AttemptState = | 'initializing' | 'submitting' | 'pending' | 'status unknown' | 'storage failed' | 'completed' | 'failed'; status unknown does not require a provider task ID. It can describe a submission whose response was lost before an ID could be recorded. It can also describe an accepted task whose current result cannot be established. storage failed is different. An image may have been generated successfully while saving or preparing its deliverable failed. That calls for recovering delivery of the existing task, not generating another image. The simplified flow is: php initializing - submitting - pending - completed | | | +- storage failed - completed | retry delivery v status unknown | +- pending / completed / storage failed | when an identified task can be checked +- failed only when failure is established A confirmed rejection or generation failure can also lead to failed. No provider ID: uncertainty remains; lookup alone cannot resolve it. Reserve application credits before attempting submission. Persist enough information to connect the reservation to the local attempt. After acceptance, retain the reservation while the task is pending. After an ambiguous submission, retain it while the outcome is unknown. Neither a missing task ID nor a failed status query establishes that the generation failed. Our service marks an ambiguous submission as status unknown with SUBMISSION UNKNOWN . Its settlement path handles terminal states; it does not release that reservation just because the submission response was lost. For the normal generation path: That policy leaves unresolved work to handle. An operator may eventually need a documented reconciliation, compensation, or expiry policy. Any such decision should record its reason separately from the provider outcome. Returning a user's credits as compensation does not prove the provider never performed the work. This article does not establish a complete policy for those unresolved attempts. With a provider task ID , query that task. If it is still processing, preserve the attempt and reservation. If the status query itself fails, preserve the uncertainty. If generation succeeds, retrieve and save the result before treating it as delivered. An upstream success response is not enough to settle as a completed delivery when the image is still unavailable to the user. Our completion path checks the stored artwork and asset availability. Without a provider task ID , this integration cannot perform its normal task-ID lookup. Replaying the same local request returns the existing attempt rather than automatically creating another provider task. Some providers offer lookup by a caller-supplied reference, or support idempotent submission. Those capabilities must be confirmed for the specific API, including their retention and request-matching rules. A local request ID is not automatically an upstream idempotency key. If no recovery mechanism is available, keep the distinction visible: the outcome is unresolved. A separately requested new generation may incur another provider charge. It should not be disguised as a harmless retry of the original task. Suppose a provider finishes an image, but saving it fails. Repeating generation would discard the opportunity to deliver an already-created result and could incur another upstream charge. Our service preserves storage failed as a recoverable state. A later query can retry delivery of the same task. The application retains the reservation until it confirms a deliverable result or reaches another explicit settlement decision. Here is a small decision sketch. It chooses a recovery action; it does not perform I/O or implement billing: type Observation = | { kind: 'submission unknown' } | { kind: 'query unknown' } | { kind: 'pending' } | { kind: 'storage failed' } | { kind: 'confirmed failure' } | { kind: 'delivery confirmed' }; type NextAction = | 'preserve attempt' | 'retry existing delivery' | 'release once' | 'settle once'; function nextAction observation: Observation : NextAction { switch observation.kind { case 'submission unknown': case 'query unknown': case 'pending': return 'preserve attempt'; case 'storage failed': return 'retry existing delivery'; case 'confirmed failure': return 'release once'; case 'delivery confirmed': return 'settle once'; } } The word once is a requirement, not a guarantee provided by this function. The executor needs durable transition checks, concurrency control, and idempotent ledger operations. It also needs recovery if it crashes after storing the image but before recording settlement. An object store and a billing database do not become one atomic transaction because the calls appear next to each other in code. In TatSketch https://tatsketch.com/ , the AI tattoo design tool I work on, two test definitions capture this distinction: These describe tests present in the repository, not a fresh test run or a production reliability measurement. For your own integration, also check request-ID reuse with changed input, rapid duplicate submissions, concurrent queries, transient query failures, duplicate settlement attempts, and interruption between delivery and settlement. Verify both the provider call count and the ledger outcome. The goal is to keep a network failure from silently becoming a new purchase of upstream work. Preserve the attempt, record what is known, and make recovery and settlement explicit. That gives you a way to investigate uncertainty without pretending it has disappeared. This article was written with AI assistance.