Skip to content
Consultation

Cron Sent It Twice? Install an Idempotent Async Contract

Adityo Guni Waluyo

A crashed cron loop resent the same prompt. A send-then-poll async contract plus two state files make Qwen chat generation safe to re-execute.

TL;DR

After a cron crash resent duplicate prompts and burned quota, the author swapped queue infrastructure for two empty files as idempotency keys: one marks the prompt sent, the other the result done. A four-state exit code contract (42, 3, 0, 1) mirrors the async request-reply pattern client-side. Since cron retries crashed jobs, making re-runs harmless beats a database or broker.

I learned this the hard way. My cron loop crashed mid-generate, right after the prompt had been sent. The next cycle came around, found no sign of the job, and resent the exact same prompt. One article, two identical chat sessions, and my free chat.qwen.ai quota burned for nothing extra. In an automated pipeline like the one behind adityo.web.id, these little failures pile up quietly, taxing every cycle.

My first instinct was to reach for RabbitMQ or a Redis-backed queue. Almost every tutorial on async orchestration starts there. But once I actually dissected the problem, the requirements were much thinner: I only needed answers to two questions. Has this prompt already been sent, and does the result exist yet. Two empty files can answer both.

Send first, ask about results later

I borrowed the shape of the contract from a classic web pattern. The Azure Architecture Center documentation describes asynchronous request-reply like this: the client sends a request to trigger a long-running operation, and the API replies as fast as it can with HTTP 202 Accepted to acknowledge receipt, plus a location reference the client can poll for the result [7]. My pipeline plays every role in that pattern, just entirely client-side, because chat.qwen.ai offers no asynchronous job API. Exit code 42 plays the part of 202: the prompt is in, the answer is coming, this is not a failure. The sidecar file .md.url plays the part of the Location header by storing the chat address, and checking on it is just re-running the same generate command.

The exit-code contract has exactly four states. Exit 42 means queued, exit 3 means still processing, exit 0 means the result is ready to collect, and exit 1 is the only reason to genuinely switch strategy. I locked the check interval at around 60 seconds, in the same spirit as the Retry-After header recommended in that pattern: the server tells the client when to ask again so it does not hammer the status endpoint [7]. The difference is that my script sets its own schedule.

Two files as the idempotency key

Idempotency works like this: when generate runs again, it sees .md.url already exists and chooses to check for results instead of resending the prompt. If the result file .md also exists, the whole thing becomes a no-op. AWS docs define an idempotent method as one where the intended effect of multiple identical requests is the same as the effect of a single request [8], and MDN adds that a client may safely retry an idempotent request when in doubt about whether the first one arrived [9]. That is exactly the state of a crashed cron run: nobody knows whether the prompt got processed, so a re-run has to be safe either way.

The field reference I measured this against comes from Stripe. Their API supports idempotency keys so requests can be repeated safely without the risk of performing the same operation twice [11], and the result of the first request is saved per key, so retries with the same key return the same result [11]. My two files sit in the same position: .md.url is the "already sent" key and .md is the "already finished" key. Deliberately no database; empty files need no migrations and their state cannot drift.

What makes this approach click for me is that cron itself is a natural retry machine. The cron daemon examines all stored crontabs and runs every job due in the current minute [10], so a crashed job will be re-executed; the only thing under my control is making sure that re-execution never pays twice. RFC 9110 draws the same line: a client should not automatically retry a non-idempotent request without some way to detect that the original request was never applied [8]. Rather than betting on luck, I made the repetition harmless.

For me the lesson is not about Qwen, it is about contracts. As long as a cron script has state you can ask "have you run already", it does not need queue infrastructure. Once the async and idempotency contract is installed client-side, switching engines, changing schedules, or crashing mid-cycle stops meaning extra cost. I already used related patterns for reusing one Qwen chat for two articles and for falling back to router9 when the free chat misbehaves; this contract closes the last gap, because in a small pipeline the most expensive event is still an order that ships twice.

Sources:

Related articles