付費生成 API 逾時不等於任務失敗:安全重試設計
A Timeout Is Not a Failed AI Job: Designing Safe Retries for Paid Generation APIs
SpicyAPI 團隊説明,付費生成 API 逾時只表示未收到回應,不代表任務失敗;重試應沿用原操作與冪等鍵。其媒體任務 API 的重播窗口自接受起為 24 小時,恢復期間應保留原請求及相同 API key;取得遠端任務 ID 後則停止重新提交。文章亦區分提交是否獲接受、生成是否完成及成本是否結算,並建議測試任務已建立但回應遺失的情況。
A customer clicks Generate. Your server sends the request, then the connection times out. The tempting fix is to send another request. The expensive possibility is that the first job is already running.
This is a distributed-systems problem with a visible price tag. The client knows that it did not receive an answer; it does not know whether the server accepted the work.
We build SpicyAPI, so the API examples below use our media task protocol. The application design applies to other asynchronous services too, but each service has its own replay guarantees. Check those guarantees before sharing a retry helper across providers.
Separate a user's intent from an HTTP attempt
Give each intentional generation an operation record in your own database. An operation might be “make take 2 for shot 7.” Several HTTP attempts can belong to that one operation.
An intentional third take is a different operation, even if the prompt happens to be identical. A network retry of take 2 is still take 2.
A useful record contains:
- Your operation ID and the owning customer or project.
- A stable idempotency key generated once for that operation.
- The exact request body and a reference to the API credential that will send it.
- The creation time and an application recovery deadline.
- The remote task ID once you obtain one.
- A state that can represent uncertainty.
Store sensitive input in a protected application record; do not copy the full body or credential into ordinary logs. Create the record transactionally before making the network call. Generating a UUID in memory is insufficient if a worker can crash before saving it.
Add an “unknown” state
A small application state machine can be enough:
prepared -> submission_unknown -> accepted -> terminal
\-> rejected
“Submission unknown” means that the next action is recovery. It should not be presented as proof that the model failed.
An operation can enter that state when a socket closes, a client deadline expires, or a response cannot be parsed. Even a successful server response can be lost before your application commits the task ID.
Once the remote task ID is durable, stop resubmitting. Follow that task through callbacks or status reads. These are two separate problems: recovering acceptance and waiting for completion.
Freeze the submission while recovering it
For SpicyAPI media tasks, reuse the same Idempotency-Key, API key and request semantics to recover an accepted submission. The replay window lasts 24 hours from acceptance; it is not a permanent deduplication service. Model, input or callback changes make it a different request. See the idempotency contract.
That has practical consequences:
- Do not regenerate a key inside a retry loop.
- Do not reconstruct a prompt from mutable project settings during recovery.
- Do not switch credentials and assume the old task will be visible.
- Do not keep an automatic recovery queue running beyond the service's replay window.
Use an application deadline much shorter than that window. If recovery is still unresolved, flag the operation for inspection instead of quietly submitting a fresh job.
Keep one HTTP attempt small
Here is a deliberately limited Node.js helper. It makes one attempt and reports what the caller actually knows. A durable worker, rather than this function, decides when to try the saved operation again.
operation.requestBody is a saved JSON string. Build it from the current model schema and your confirmed request before calling this function. The example does not select a model or start a generation by itself.
export async function sendSavedOperation(operation, apiKey, send = fetch) {
let response;
let envelope;
try {
response = await send(
'https://api.spicyapi.ai/api/v1/jobs/createTask',
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': operation.idempotencyKey,
},
body: operation.requestBody,
signal: AbortSignal.timeout(20_000),
},
);
envelope = await response.json();
} catch {
return { kind: 'unknown', operationId: operation.id };
}
const taskId = envelope?.data?.taskId;
if ([200, 202].includes(response.status)
&& envelope?.code === 200 && taskId) {
return { kind: 'accepted', taskId, requestId: envelope.request_id };
}
// This is a response to classify, not permission to create another job.
return {
kind: 'response_to_review',
httpStatus: response.status,
businessCode: envelope?.code,
requestId: envelope?.request_id,
};
}
The helper intentionally does not label every non-success response “safe to retry.” Invalid input, authentication failures, spend limits and conflicting keys need different handling. Rate limits require bounded backoff; follow the service's retry guidance. HTTP status alone is often insufficient when an API also has business error codes.
After an accepted result, commit the task ID before acknowledging your own queue message. After unknown, retain the same saved operation for recovery. Do not catch every exception and create a new operation.
For multiple workers, claim or lease the operation atomically. Idempotency at the remote API does not prevent two local workers from sending duplicate emails or writing two output records.
Do not confuse acceptance, completion and payment
In this protocol, an HTTP 202 response acknowledges a submitted task. It does not say that generation succeeded. The task's state describes the generation outcome, while settled tells you whether its reported cost is final. A local timeout does not cancel accepted work. The production integration guide describes those boundaries and cost confirmation.
Your interface can reflect this without exposing protocol jargon:
- “Checking whether your generation started…” during uncertain submission.
- “Generating…” after acceptance.
- A completed or failed result only after reading a terminal task state.
An explicit “Generate another take” action should create another operation with a new key. A “Check progress” action should keep following the existing one.
Test the lost response, not only a failed connection
A mock that always throws before accepting anything misses the difficult case. Make your fake server remember the operation, create one task, and then drop the first response. Send the saved operation again.
The useful assertions are:
- Two HTTP attempts carry the same key and body.
- The fake server creates exactly one remote task.
- The recovered task ID is committed locally.
- A process restart reads the old operation instead of generating another key.
- Two callbacks for that task produce one application-side effect.
- An operation beyond its recovery deadline is escalated rather than resubmitted automatically.
Also test a changed body with the old key. A conflict should be visible to your operator, not “fixed” by silently inventing a new key and spending money.
The key question for each failure path is simple: what do we know, and which durable record lets the next worker continue from there?
Written by the SpicyAPI Team. Examples describe our media task API; streaming and chat endpoints have different replay behavior.
來源:DEV Community · dev.to