{"slug": "same-audio-four-request-ids-keying-idempotency-on-the-bytes-so-a-phone-can", "title": "Same audio, four request IDs: keying idempotency on the bytes so a phone can resend safely", "summary": "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.", "body_md": "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:\n\nThe fix for the second one is only safe because of the fix for the first. This post walks through both.\n\n*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.*\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nSo the key became the device plus a SHA-256 of the uploaded bytes:\n\n``` js\nconst serverAudioHash = crypto.createHash('sha256').update(req.file.buffer).digest('hex');\nconst suppliedAudioHash = String(req.headers['x-bolo-audio-sha256'] || '').toLowerCase();\nif (suppliedAudioHash && suppliedAudioHash !== serverAudioHash) {\n    return res.status(400).json({ error: 'audio hash mismatch' });\n}\n\nconst idempotencyKey = `${deviceId}:audio:${serverAudioHash}`;\nconst execution = await transcriptionIdempotency.execute(\n    idempotencyKey,\n    serverAudioHash,\n    () => transcribe(req.file /* , ... */)\n);\n```\n\nTwo choices worth pointing out:\n\nThe request ID is still logged on every line. It just no longer decides identity.\n\n```\nclass IdempotencyStore {\n    constructor({ ttlMs = 10 * 60 * 1000 } = {}) {\n        this.ttlMs = Math.max(1000, Number(ttlMs) || 10 * 60 * 1000);\n        this.entries = new Map();\n    }\n\n    async execute(key, audioHash, operation) {\n        this.cleanup();\n        const existing = this.entries.get(key);\n        if (existing) {\n            if (existing.audioHash !== audioHash) {\n                const error = new Error('Request ID was already used for different audio');\n                error.statusCode = 409;\n                throw error;\n            }\n            return { value: await existing.promise, deduplicated: true };\n        }\n\n        const entry = { audioHash, expiresAt: Date.now() + this.ttlMs, promise: null };\n        entry.promise = Promise.resolve().then(operation);\n        this.entries.set(key, entry);\n        try {\n            return { value: await entry.promise, deduplicated: false };\n        } catch (error) {\n            // Failed attempts are retryable with the same key; only completed work is cached.\n            this.entries.delete(key);\n            throw error;\n        }\n    }\n\n    cleanup(now = Date.now()) {\n        for (const [key, entry] of this.entries.entries()) {\n            if (entry.expiresAt <= now) this.entries.delete(key);\n        }\n    }\n}\n```\n\nWhat makes this work for the \"two uploads 44 ms apart\" case:\n\nA 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.\n\nWith 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:\n\n``` js\nobject TranscriptionResend {\n    const val MAX_ATTEMPTS = 4                       // including the first\n    val BACKOFF_MS = listOf(2_000L, 5_000L, 10_000L) // before attempts 2, 3, 4\n    const val RESEND_WINDOW_MS = 90_000L             // no new attempt after this\n\n    fun isTransient(e: Throwable): Boolean = when (e) {\n        is VoxException -> false                     // the app's own \"this request is wrong\"\n        is java.net.UnknownHostException -> true\n        is java.io.IOException -> !isCallCancelled(e)\n        is retrofit2.HttpException -> e.code() in TRANSIENT_HTTP\n        else -> false\n    }\n\n    // 500-504 from the proxy or nginx; 520-524 are Cloudflare's \"origin did not answer in time\".\n    private val TRANSIENT_HTTP = setOf(500, 502, 503, 504, 520, 521, 522, 523, 524)\n\n    // OkHttp reports its own cancel (the user tapped stop) as IOException(\"Canceled\").\n    private fun isCallCancelled(e: java.io.IOException) =\n        e.message.equals(\"Canceled\", ignoreCase = true)\n}\n```\n\nAnd the loop:\n\n``` php\nsuspend fun <T> run(\n    nowMs: () -> Long = System::currentTimeMillis,\n    sleep: suspend (Long) -> Unit = { delay(it) },\n    block: suspend (attempt: Int) -> T\n): T {\n    val startedAt = nowMs()\n    var attempt = 1\n    while (true) {\n        try {\n            return block(attempt)\n        } catch (e: Exception) {\n            currentCoroutineContext().ensureActive()\n            val pause = BACKOFF_MS.getOrElse(attempt - 1) { BACKOFF_MS.last() }\n            val outOfTime = nowMs() - startedAt + pause > RESEND_WINDOW_MS\n            if (!isTransient(e) || attempt >= MAX_ATTEMPTS || outOfTime) throw e\n            attempt++\n            sleep(pause)\n        }\n    }\n}\n```\n\nThe rules it encodes:\n\n`ensureActive()` rethrows if the coroutine was cancelled, and OkHttp's own \"Canceled\" IOException is treated as final. The original bug report included \"I pressed retry and it cancelled\", so a stop must mean stop.\nOne more piece: a fixed 30-second read timeout cut off slow but healthy answers for long notes. The timeout is now 30 s plus half the note's length, capped at 2 minutes, applied by an OkHttp interceptor that reads the duration header the upload already carries:\n\n```\nfun readTimeoutMsFor(audioDurationMs: Long): Long =\n    (30_000L + audioDurationMs.coerceAtLeast(0L) / 2).coerceIn(30_000L, 120_000L)\n```\n\nOther calls keep the client's default timeouts.\n\nBolo is on Google Play as \"Bolo AI Voice Typing & PC Sync\" (search \"Bolo AI\"): [https://play.google.com/store/apps/details?id=com.ashwar.bolo&utm_source=devto&utm_medium=article&utm_campaign=2026-w45-idempotency](https://play.google.com/store/apps/details?id=com.ashwar.bolo&utm_source=devto&utm_medium=article&utm_campaign=2026-w45-idempotency). Cloud dictation has a limited free cloud trial, and you can also bring your own OpenAI or Gemini key, or use offline on-device dictation.", "url": "https://wpnews.pro/news/same-audio-four-request-ids-keying-idempotency-on-the-bytes-so-a-phone-can", "canonical_source": "https://dev.to/ashwar_sadh_f7abe6f82c8e9/same-audio-four-request-ids-keying-idempotency-on-the-bytes-so-a-phone-can-resend-safely-2858", "published_at": "2026-11-06 06:16:34+00:00", "updated_at": "2026-11-06 06:22:28.917765+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products"], "entities": ["Bolo"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/same-audio-four-request-ids-keying-idempotency-on-the-bytes-so-a-phone-can", "markdown": "https://wpnews.pro/news/same-audio-four-request-ids-keying-idempotency-on-the-bytes-so-a-phone-can.md", "text": "https://wpnews.pro/news/same-audio-four-request-ids-keying-idempotency-on-the-bytes-so-a-phone-can.txt", "jsonld": "https://wpnews.pro/news/same-audio-four-request-ids-keying-idempotency-on-the-bytes-so-a-phone-can.jsonld"}}