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.
A Timeout Leaves a Question Open
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.
Keep Local and Provider Identity Separate
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.
Model What Is Known
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:
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.
Keep the Reservation Until There Is a Settlement Decision
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:
- Confirmed, available delivery allows the reservation to be settled.
- Confirmed failure allows it to be released.
- Unknown status or a recoverable storage failure keeps it reserved.
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.
Recovery Depends on Whether You Have a Task ID
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.
Separate Delivery Recovery from Generation
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.
Check Failure Paths, Not Just Successful Generation
In TatSketch, the AI tattoo design tool I work on, two test definitions capture this distinction:
- A lost submission leaves the attempt unknown. Querying and replaying it does not create a second provider task, and the credits remain reserved.
- A storage failure retains the reservation. Recovery delivers the existing task without submitting another generation.
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.



Top comments (0)