Same audio, four request IDs: keying idempotency on the bytes so a phone can resend safely The developer of Bolo, a phone-based speech-to-text app, replaced client-supplied request IDs with a server-side idempotency key built from the device ID plus a SHA-256 hash of the uploaded audio bytes, after logs from 31 real dictations showed roughly 13% of uploads were redundant and one recording was sent four times with four fresh UUIDs. The new IdempotencyStore caches in-flight and completed transcriptions for a 10-minute TTL, returns the first result for byte-identical resends, rejects mismatched audio hashes with a 400, and leaves failed attempts retryable. Bolo turns speech on a phone into text. In cloud mode the phone records a voice note, uploads it to a small proxy, and the proxy calls a speech-to-text service. Two problems in that path turned out to be the same problem seen from opposite ends: The fix for the second one is only safe because of the fix for the first. This post walks through both. Disclosure: I'm the developer of Bolo, and this post is published from my account. It was written and published by an AI agent working on my behalf. The proxy already had an idempotency guard. The client sent an X-Bolo-Request-Id header, and a repeat of the same ID was supposed to return the first result instead of calling the speech service again. The logs from 31 real dictations on one phone showed it collapsed nothing. One recording had been uploaded four times, with identical bytes, two of the uploads 44 ms apart. Each upload carried a fresh request ID, because the app minted a new UUID per call. Four IDs, four upstream calls, four quota charges. About 13% of the uploads in that sample were redundant. The root cause on the phone was a race between two code paths that could both send the same recording, and that was fixed in the app. But an app build that is already installed cannot be patched from the server, so the server needed its own net. The question idempotency answers is "have I already done this piece of work?". For a transcription, the work is defined by who sent it and what audio it is. The request ID is just a label the client chose. So the key became the device plus a SHA-256 of the uploaded bytes: js const serverAudioHash = crypto.createHash 'sha256' .update req.file.buffer .digest 'hex' ; const suppliedAudioHash = String req.headers 'x-bolo-audio-sha256' || '' .toLowerCase ; if suppliedAudioHash && suppliedAudioHash == serverAudioHash { return res.status 400 .json { error: 'audio hash mismatch' } ; } const idempotencyKey = ${deviceId}:audio:${serverAudioHash} ; const execution = await transcriptionIdempotency.execute idempotencyKey, serverAudioHash, = transcribe req.file / , ... / ; Two choices worth pointing out: The request ID is still logged on every line. It just no longer decides identity. class IdempotencyStore { constructor { ttlMs = 10 60 1000 } = {} { this.ttlMs = Math.max 1000, Number ttlMs || 10 60 1000 ; this.entries = new Map ; } async execute key, audioHash, operation { this.cleanup ; const existing = this.entries.get key ; if existing { if existing.audioHash == audioHash { const error = new Error 'Request ID was already used for different audio' ; error.statusCode = 409; throw error; } return { value: await existing.promise, deduplicated: true }; } const entry = { audioHash, expiresAt: Date.now + this.ttlMs, promise: null }; entry.promise = Promise.resolve .then operation ; this.entries.set key, entry ; try { return { value: await entry.promise, deduplicated: false }; } catch error { // Failed attempts are retryable with the same key; only completed work is cached. this.entries.delete key ; throw error; } } cleanup now = Date.now { for const key, entry of this.entries.entries { if entry.expiresAt <= now this.entries.delete key ; } } } What makes this work for the "two uploads 44 ms apart" case: A later report came from a home connection: a 24-second note timed out, the single automatic retry died after 5 seconds, and a manual resend went through and was transcribed in 1.5 seconds. The audio was fine and the server was fine. The client simply did not resend patiently enough. With the server-side key in place, resending the same audio is harmless. If the earlier upload did land, the resend gets the cached text: no second speech call, no second quota charge. So the app can afford to try harder: js object TranscriptionResend { const val MAX ATTEMPTS = 4 // including the first val BACKOFF MS = listOf 2 000L, 5 000L, 10 000L // before attempts 2, 3, 4 const val RESEND WINDOW MS = 90 000L // no new attempt after this fun isTransient e: Throwable : Boolean = when e { is VoxException - false // the app's own "this request is wrong" is java.net.UnknownHostException - true is java.io.IOException - isCallCancelled e is retrofit2.HttpException - e.code in TRANSIENT HTTP else - false } // 500-504 from the proxy or nginx; 520-524 are Cloudflare's "origin did not answer in time". private val TRANSIENT HTTP = setOf 500, 502, 503, 504, 520, 521, 522, 523, 524 // OkHttp reports its own cancel the user tapped stop as IOException "Canceled" . private fun isCallCancelled e: java.io.IOException = e.message.equals "Canceled", ignoreCase = true } And the loop: php suspend fun