cd /news/ai-tools/same-audio-four-request-ids-keying-i… · home › topics › ai-tools › article
[ARTICLE · art-165157] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=↑ positive

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.

by read5 min views1 publishedNov 6, 2026

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:

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:

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:

suspend fun <T> run(
    nowMs: () -> Long = System::currentTimeMillis,
    sleep: suspend (Long) -> Unit = { delay(it) },
    block: suspend (attempt: Int) -> T
): T {
    val startedAt = nowMs()
    var attempt = 1
    while (true) {
        try {
            return block(attempt)
        } catch (e: Exception) {
            currentCoroutineContext().ensureActive()
            val  = BACKOFF_MS.getOrElse(attempt - 1) { BACKOFF_MS.last() }
            val outOfTime = nowMs() - startedAt +  > RESEND_WINDOW_MS
            if (!isTransient(e) || attempt >= MAX_ATTEMPTS || outOfTime) throw e
            attempt++
            sleep()
        }
    }
}

The rules it encodes:

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. One 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:

fun readTimeoutMsFor(audioDurationMs: Long): Long =
    (30_000L + audioDurationMs.coerceAtLeast(0L) / 2).coerceIn(30_000L, 120_000L)

Other calls keep the client's default timeouts.

Bolo 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. 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.

── more in #ai-tools 4 stories · sorted by recency
── more on @bolo 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/same-audio-four-requ…] indexed:0 read:5min 2026-11-06 · —