From c18004f5245a5d2b91912231e1eaa0575f135ed5 Mon Sep 17 00:00:00 2001 From: MyButtermilk Date: Mon, 28 Sep 2026 17:17:55 +0200 Subject: [PATCH 1/3] fix: retry transient OpenRouter transcription limits safely --- AGENTS.md | 14 + .../src/i18n/translations/de/transcription.ts | 7 + .../src/pages/TranscriptDetail.test.tsx | 79 ++++++ .../client/src/pages/TranscriptDetail.tsx | 85 +++--- src/cloud_async_stt.py | 151 +++++++--- src/core/provider_errors.py | 22 +- src/web_api.py | 10 +- tests/core/test_openrouter_stt_retry.py | 263 ++++++++++++++++++ tests/core/test_provider_errors.py | 31 +++ tests/test_web_api_lifecycle.py | 32 ++- 10 files changed, 605 insertions(+), 89 deletions(-) create mode 100644 Frontend/client/src/pages/TranscriptDetail.test.tsx create mode 100644 tests/core/test_openrouter_stt_retry.py diff --git a/AGENTS.md b/AGENTS.md index b797540c..b120b7b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1503,6 +1503,20 @@ Packaging and scripts: OpenRouter `error.metadata.raw` upstream errors without retaining free-form provider messages. Keep these fields in the debug-console allowlist; never restore raw response bodies to exceptions, logs, or support bundles. + Retry confirmed HTTP 429 responses at most twice, within a five-second + retry-admission window and the original overall request deadline. Respect + `Retry-After` seconds and HTTP dates; do not shorten a server wait to fit that + window. Without a valid hint, use bounded exponential backoff with jitter. + Retain the original audio and rebuild each JSON body because aiohttp closes + uploaded streams. Non-seekable audio, explicit credit/spending failures, + other HTTP errors, ambiguous response timeouts, and cancellation must never + trigger an automatic replay. HTTP 429 alone does not establish missing + OpenRouter credit; keep its temporary rate-limit message distinct from 402. +- Failed Live Mic records persist their safe provider message in `step`, with + only recognized speech in transcript content. Preserve partial speech and + empty failed history entries. Transcript Detail shows the failure separately + and excludes legacy error-only content from word counts and copy/export + actions; never add an error string as a transcript segment. - Meta language-model choices are `muse-spark-1.3` and `muse-spark-1.3-contributor`; exact persisted 1.2 built-in selections migrate to their matching 1.3 tier without rewriting custom model codes. This also diff --git a/Frontend/client/src/i18n/translations/de/transcription.ts b/Frontend/client/src/i18n/translations/de/transcription.ts index 88988800..fa158927 100644 --- a/Frontend/client/src/i18n/translations/de/transcription.ts +++ b/Frontend/client/src/i18n/translations/de/transcription.ts @@ -1,6 +1,13 @@ import type { TranslationCatalog } from "@/i18n/types"; export const transcriptionTranslations = { + "Waiting before retrying transcription...": "Kurze Wartezeit vor dem nächsten Transkriptionsversuch…", + "Microsoft MAI Transcribe via OpenRouter is temporarily rate limited (HTTP 429). Wait briefly or switch transcription provider.": + "Microsoft MAI Transcribe über OpenRouter ist vorübergehend ausgelastet oder durch ein Ratenlimit begrenzt (HTTP 429). Warte kurz oder wechsle den Transkriptionsanbieter.", + "Microsoft MAI Transcribe via OpenRouter reports insufficient credits or a spending limit (HTTP 402). Check the provider billing limits.": + "Microsoft MAI Transcribe über OpenRouter meldet fehlendes Guthaben oder ein Ausgabenlimit (HTTP 402). Prüfe die Abrechnungslimits beim Anbieter.", + "Microsoft MAI Transcribe via OpenRouter reports insufficient credits or a spending limit (HTTP 429). Check the provider billing limits.": + "Microsoft MAI Transcribe über OpenRouter meldet fehlendes Guthaben oder ein Ausgabenlimit (HTTP 429). Prüfe die Abrechnungslimits beim Anbieter.", "Preparing next recording": "Nächste Aufnahme wird vorbereitet", "Starts as soon as the microphone is ready": "Startet, sobald das Mikrofon bereit ist", "You can start your next recording now": "Du kannst jetzt die nächste Aufnahme starten", diff --git a/Frontend/client/src/pages/TranscriptDetail.test.tsx b/Frontend/client/src/pages/TranscriptDetail.test.tsx new file mode 100644 index 00000000..15d936ab --- /dev/null +++ b/Frontend/client/src/pages/TranscriptDetail.test.tsx @@ -0,0 +1,79 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Route, Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { LANGUAGE_STORAGE_KEY, LocaleProvider } from "@/i18n"; +import type { TranscriptDetailResponse } from "@/lib/api-types"; +import TranscriptDetail from "./TranscriptDetail"; +import { AppScrollContainerContext } from "@/contexts/AppScrollContainerContext"; + +vi.mock("@/hooks/use-transcript-auto-refresh", () => ({ useTranscriptAutoRefresh: () => ({ isWsConnected: true }) })); +vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false })); + +const rateLimitMessage = + "Microsoft MAI Transcribe via OpenRouter is temporarily rate limited (HTTP 429). Wait briefly or switch transcription provider."; + +function mount(content = "", step = rateLimitMessage, summary = "") { + const record: TranscriptDetailResponse = { + id: "failed-mic", + title: "Live Mic", + date: "Today", + duration: "00:03", + type: "mic", + status: "failed", + content, + step, + summary, + }; + const client = new QueryClient({ defaultOptions: { queries: { retry: false, queryFn: async () => record } } }); + const location = memoryLocation({ path: "/transcript/failed-mic" }); + return render( + + + + + + + + + + + , + ); +} + +describe("failed Live Mic transcript", () => { + beforeEach(() => { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "en"); + }); + + it("shows the persisted failure separately from transcript text and actions", async () => { + mount(); + expect(await screen.findByRole("alert")).toHaveTextContent(rateLimitMessage); + expect(screen.getByText("No transcript text captured.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Copy transcript" })).toBeNull(); + expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); + expect(screen.queryByText(/\d+ words/)).toBeNull(); + }); + + it("copies and counts only retained speech when a recording fails", async () => { + const user = userEvent.setup(); + mount("Retained speech."); + expect(await screen.findByRole("alert")).toHaveTextContent(rateLimitMessage); + expect(screen.getByText("2 words")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Copy transcript" })); + expect(await navigator.clipboard.readText()).toBe("Retained speech."); + expect(screen.getByRole("button", { name: "Export" })).toBeEnabled(); + }); + + it("does not count or offer to copy a legacy error-only transcript", async () => { + mount(`[Error] ${rateLimitMessage}`, "Transcribing...", "Earlier summary"); + expect(await screen.findByRole("alert")).toHaveTextContent(rateLimitMessage); + expect(screen.queryByText(/\d+ words/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Copy transcript" })).toBeNull(); + expect(screen.getByText("No transcript text captured.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); + }); +}); diff --git a/Frontend/client/src/pages/TranscriptDetail.tsx b/Frontend/client/src/pages/TranscriptDetail.tsx index 785d6bcf..ea963bc1 100644 --- a/Frontend/client/src/pages/TranscriptDetail.tsx +++ b/Frontend/client/src/pages/TranscriptDetail.tsx @@ -437,13 +437,14 @@ export default function TranscriptDetail() { const summaryMarkdown = useMemo(() => normalizeSummaryMarkdown(summarySource), [summarySource]); const summaryCopyText = summaryFormat === "html" ? preparedSummaryHtml.plainText : summaryMarkdown; const hasSummary = summarySource.length > 0; + const isFailedTranscript = transcript.status === "failed"; + const failedContentLooksLikeErrorOnly = + isFailedTranscript && /^\[(error|timeout|download error)\]/i.test(String(transcript.content || "").trim()); + const transcriptContent = failedContentLooksLikeErrorOnly ? "" : String(transcript.content || ""); + const hasExportableContent = !failedContentLooksLikeErrorOnly && Boolean(transcriptContent.trim() || hasSummary); const transcriptWordCount = useMemo( - () => - String(transcript.content || "") - .trim() - .split(/\s+/) - .filter(Boolean).length, - [transcript.content], + () => transcriptContent.trim().split(/\s+/).filter(Boolean).length, + [transcriptContent], ); const summaryStatus = String(transcript?.summaryStatus || (hasSummary ? "completed" : "idle")).toLowerCase(); const summaryStepLower = String(transcript?.step || "").toLowerCase(); @@ -462,12 +463,15 @@ export default function TranscriptDetail() { transcript.status === "completed" && !hasSummary && !isSummaryInProgress && !isSummaryFailed; const isFailedYoutubeTranscript = transcript?.status === "failed" && transcript?.type === "youtube"; const rawFailureMessage = useMemo( - () => extractFailureMessage(String(transcript?.content || ""), String(transcript?.step || "")), - [transcript?.content, transcript?.step], + () => + transcript.type === "mic" && !failedContentLooksLikeErrorOnly + ? String(transcript.step || "") + : extractFailureMessage(String(transcript.content || ""), String(transcript.step || "")), + [failedContentLooksLikeErrorOnly, transcript.content, transcript.step, transcript.type], ); const failedMessage = useMemo( - () => (isFailedYoutubeTranscript ? t(friendlyRequestMessage(rawFailureMessage, t("Transcription failed."))) : ""), - [isFailedYoutubeTranscript, rawFailureMessage, t], + () => (isFailedTranscript ? t(friendlyRequestMessage(rawFailureMessage, t("Transcription failed."))) : ""), + [isFailedTranscript, rawFailureMessage, t], ); const technicalFailureMessage = useMemo(() => { if (!isFailedYoutubeTranscript) return ""; @@ -475,11 +479,6 @@ export default function TranscriptDetail() { if (!technical || technical === failedMessage) return ""; return technical; }, [failedMessage, isFailedYoutubeTranscript, rawFailureMessage]); - const failedContentLooksLikeErrorOnly = useMemo(() => { - if (!isFailedYoutubeTranscript) return false; - const content = String(transcript?.content || "").trim(); - return /^\[(error|timeout|download error)\]/i.test(content); - }, [isFailedYoutubeTranscript, transcript?.content]); const retryYoutubeTranscription = useCallback(async () => { if (!id || isRetryingYoutube) return; @@ -568,7 +567,7 @@ export default function TranscriptDetail() { if (!navigator.clipboard?.writeText) { throw new Error(t("Clipboard API unavailable")); } - await navigator.clipboard.writeText(transcript?.content || ""); + await navigator.clipboard.writeText(transcriptContent); setCopied(true); if (copyResetTimerRef.current !== null) { window.clearTimeout(copyResetTimerRef.current); @@ -587,7 +586,7 @@ export default function TranscriptDetail() { variant: "destructive", }); } - }, [t, toast, transcript?.content]); + }, [t, toast, transcriptContent]); const handleCopySummary = useCallback(async () => { try { @@ -617,7 +616,7 @@ export default function TranscriptDetail() { const handleExport = useCallback( async (format: "pdf" | "docx") => { - if (!id || isExporting) return; + if (!id || isExporting || !hasExportableContent) return; setIsExporting(true); try { const result = await saveTranscriptExport( @@ -642,7 +641,7 @@ export default function TranscriptDetail() { setIsExporting(false); } }, - [id, isExporting, t, toast, transcript?.title], + [hasExportableContent, id, isExporting, t, toast, transcript?.title], ); const getBackLink = () => { @@ -720,7 +719,7 @@ export default function TranscriptDetail() { )} - {transcript.content && ( + {transcriptContent && ( - void handleExport("pdf")}> + void handleExport("pdf")} + > {isExporting ? ( ) : ( @@ -765,7 +768,10 @@ export default function TranscriptDetail() { )}{" "} {t("Export as PDF")} - void handleExport("docx")}> + void handleExport("docx")} + > {isExporting ? ( ) : ( @@ -807,7 +813,7 @@ export default function TranscriptDetail() { )} - {transcript.content && ( + {transcriptContent && ( {t("Copy transcript")} @@ -817,7 +823,10 @@ export default function TranscriptDetail() { {t("Copy summary")} )} - void handleExport("pdf")}> + void handleExport("pdf")} + > {isExporting ? ( ) : ( @@ -825,7 +834,10 @@ export default function TranscriptDetail() { )}{" "} {t("Export as PDF")} - void handleExport("docx")}> + void handleExport("docx")} + > {isExporting ? ( ) : ( @@ -896,14 +908,19 @@ export default function TranscriptDetail() { )} - {isFailedYoutubeTranscript && ( + {isFailedTranscript && (
{ - void retryYoutubeTranscription(); - }} + onRetry={ + isFailedYoutubeTranscript + ? () => { + void retryYoutubeTranscription(); + } + : undefined + } /> {technicalFailureMessage && (

@@ -1007,12 +1024,8 @@ export default function TranscriptDetail() { t("Loading…") ) : transcript.status === "processing" ? ( - ) : isFailedYoutubeTranscript && failedContentLooksLikeErrorOnly ? ( - - {failedMessage || t("No transcript text captured.")} - - ) : transcript.content ? ( - + ) : transcriptContent ? ( + ) : ( t("No transcript text captured.") )} diff --git a/src/cloud_async_stt.py b/src/cloud_async_stt.py index 94871b5d..62b42c20 100644 --- a/src/cloud_async_stt.py +++ b/src/cloud_async_stt.py @@ -10,8 +10,11 @@ import base64 import json import os +import random import tempfile from collections.abc import Callable +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime from typing import Any, BinaryIO import aiohttp @@ -38,6 +41,7 @@ speechmatics_batch_endpoint_is_custom, ) from src.core.provider_errors import ( + ProviderTransportError, parse_provider_json_response, provider_transport_error, provider_user_error, @@ -54,10 +58,13 @@ from src.runtime.audio_spool import ( pcm_stream_to_wav as _pcm_stream_to_wav, ) +from src.runtime.cancellation import await_with_delayed_cancellation, to_thread_cancellation_barrier from src.runtime.env_values import env_float from src.runtime.http_response import read_response_text_limited OPENROUTER_STT_URL = "https://openrouter.ai/api/v1/audio/transcriptions" +_OPENROUTER_STT_MAX_ATTEMPTS = 3 +_OPENROUTER_STT_RETRY_WINDOW_SECONDS = 5.0 OPENROUTER_MAI_TRANSCRIBE_MODEL = Config.DEFAULT_OPENROUTER_STT_MODEL # The legacy model is accepted only when explicitly supplied by a frozen # execution route. Current admissions and the transport default stay on 2. @@ -805,6 +812,21 @@ async def transcribe_with_openai_audio_transcription( return parsed if isinstance(parsed, dict) else {"text": raw} +def _openrouter_retry_delay(header: str | None, attempt: int) -> float: + """Honor seconds or an HTTP date; malformed hints use bounded backoff.""" + value = str(header or "").strip() + if value.isascii() and value.isdecimal(): + return float(value) if len(value) <= 10 else float("inf") + if value: + try: + retry_at = parsedate_to_datetime(value) + if retry_at.tzinfo is not None: + return max(0.0, (retry_at - datetime.now(UTC)).total_seconds()) + except TypeError, ValueError, OverflowError: + pass + return 0.5 * (2**attempt) + random.uniform(0.0, 0.25) + + async def transcribe_with_openrouter_audio_transcription( *, session: aiohttp.ClientSession, @@ -825,50 +847,95 @@ async def transcribe_with_openrouter_audio_transcription( audio_format = openrouter_audio_format(filename, content_type) language_code = provider_language_code(language) - _report_progress(on_progress, "Preparing audio...") - body = await asyncio.to_thread( - _build_openrouter_stt_json_body, - audio_source, - model=selected_model, - audio_format=audio_format, - language=language_code, - ) - try: - body.seek(0, os.SEEK_END) - request_bytes = body.tell() - body.seek(0) - _report_progress(on_progress, "Uploading audio...") - _report_progress(on_progress, "Processing transcription...") - async with session.post( - OPENROUTER_STT_URL, - data=body, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - "HTTP-Referer": "https://scriber.local", - "X-OpenRouter-Title": "Scriber", - }, - timeout=aiohttp.ClientTimeout(total=timeout_secs), - ) as response: - raw = await read_response_text_limited(response, 64 * 1024 * 1024) - if response.status >= 400: - raise provider_transport_error( - "openrouter_stt", - "transcription", - status=response.status, - response_body=raw, - request_bytes=request_bytes, + source_position = None + if not isinstance(audio_source, bytes): + try: + if audio_source.seekable(): + source_position = audio_source.tell() + except AttributeError, OSError: + pass + replayable = isinstance(audio_source, bytes) or source_position is not None + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_secs + retry_deadline = None + error: ProviderTransportError | None = None + + # One deadline covers all attempts, including preparation and backoff. The + # caller's Live Mic stop deadline may cancel this operation earlier. + async with asyncio.timeout_at(deadline): + for attempt in range(_OPENROUTER_STT_MAX_ATTEMPTS): + if attempt and source_position is not None: + await to_thread_cancellation_barrier(audio_source.seek, source_position) + _report_progress(on_progress, "Preparing audio...") + # aiohttp owns/closes each uploaded body. Rebuild from the retained + # audio, never reuse the first request's possibly closed file handle. + body, pending_cancel = await await_with_delayed_cancellation( + asyncio.to_thread( + _build_openrouter_stt_json_body, + audio_source, + model=selected_model, + audio_format=audio_format, + language=language_code, ) - if not raw: - return {} - parsed = parse_provider_json_response( - "openrouter_stt", - "transcription_response", - raw, ) - return parsed if isinstance(parsed, dict) else {"text": raw} - finally: - await asyncio.to_thread(body.close) + try: + if pending_cancel is not None: + raise pending_cancel + if error is not None and retry_deadline is not None and loop.time() >= retry_deadline: + raise error + body.seek(0, os.SEEK_END) + request_bytes = body.tell() + body.seek(0) + _report_progress(on_progress, "Uploading audio...") + _report_progress(on_progress, "Processing transcription...") + async with session.post( + OPENROUTER_STT_URL, + data=body, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://scriber.local", + "X-OpenRouter-Title": "Scriber", + }, + timeout=aiohttp.ClientTimeout(total=max(0.001, deadline - loop.time())), + ) as response: + raw = await read_response_text_limited(response, 64 * 1024 * 1024) + if response.status < 400: + if not raw: + return {} + parsed = parse_provider_json_response("openrouter_stt", "transcription_response", raw) + return parsed if isinstance(parsed, dict) else {"text": raw} + error = provider_transport_error( + "openrouter_stt", + "transcription", + status=response.status, + response_body=raw, + request_bytes=request_bytes, + ) + if ( + response.status != 429 + or error.reason == "insufficient_credits" + or not replayable + or attempt + 1 == _OPENROUTER_STT_MAX_ATTEMPTS + ): + raise error + delay = _openrouter_retry_delay(getattr(response, "headers", {}).get("Retry-After"), attempt) + finally: + await to_thread_cancellation_barrier(body.close) + + # Release the response/connection and request file before waiting. + # Never shorten a server hint to fit the interactive retry budget. + if retry_deadline is None: + retry_deadline = min(deadline, loop.time() + _OPENROUTER_STT_RETRY_WINDOW_SECONDS) + if loop.time() + delay >= retry_deadline: + raise error + logger.warning( + "OpenRouter STT HTTP 429: retry {}/{} after {:.2f}s", attempt + 2, _OPENROUTER_STT_MAX_ATTEMPTS, delay + ) + _report_progress(on_progress, "Waiting before retrying transcription...") + await asyncio.sleep(delay) + + raise AssertionError("OpenRouter STT attempt limit must produce a result or error") def speechmatics_transcript_payload_to_text( diff --git a/src/core/provider_errors.py b/src/core/provider_errors.py index 6f0a1a10..26f9b1ce 100644 --- a/src/core/provider_errors.py +++ b/src/core/provider_errors.py @@ -195,7 +195,7 @@ class ProviderUserError: ("unsupported audio", "unsupported format", "unsupported codec", "could not decode", "cannot decode"), ), ("model_unavailable", ("no endpoints found", "no available provider", "model not found", "model not available")), - ("insufficient_credits", ("insufficient credits", "insufficient quota", "credit balance", "quota exceeded")), + ("insufficient_credits", ("insufficient credits", "credit balance", "spending limit")), ("rate_limited", ("rate limit", "too many requests")), ("authentication_failed", ("invalid api key", "authentication failed", "unauthorized")), ("upstream_timeout", ("gateway timeout", "timed out", "timeout")), @@ -255,6 +255,26 @@ def provider_user_error(provider: str | None, error: Exception | str) -> Provide retryable=False, ) + if normalized_provider == "openrouter_stt" and status in {402, 429}: + credit_failure = ( + status == 402 + or (transport_error is not None and transport_error.reason == "insufficient_credits") + or _has(combined, "insufficient credits", "spending limit") + ) + message = ( + f"{label} reports insufficient credits or a spending limit (HTTP {status}). Check the provider billing limits." + if credit_failure + else f"{label} is temporarily rate limited (HTTP 429). Wait briefly or switch transcription provider." + ) + return _make_error( + normalized_provider, + label, + ErrorCategory.PROVIDER_LIMIT, + message, + code=code or str(status), + retryable=not credit_failure, + ) + family = _provider_family(normalized_provider) if family == "soniox": specific = _classify_soniox(normalized_provider, label, combined, status, code) diff --git a/src/web_api.py b/src/web_api.py index 0c039782..e5d0da90 100644 --- a/src/web_api.py +++ b/src/web_api.py @@ -9258,9 +9258,8 @@ async def _broadcast_error(payload: dict[str, Any]): failed_current = self._current self._current = None if failed_current: - if not failed_current.content_text().strip() and info.category is not ErrorCategory.CONFIG_INVALID: - failed_current.append_final_text(f"[Error] {user_msg}") - if failed_current.content_text().strip(): + failed_current.step = user_msg + if failed_current.content_text().strip() or info.category is not ErrorCategory.CONFIG_INVALID: self._add_to_history(failed_current) self._schedule_transcript_save(failed_current) self._spawn_detached_threadsafe( @@ -13552,8 +13551,9 @@ async def _stop_listening_session(self, stop_owner: object) -> ProviderUserError current.finish("failed" if stop_error else "completed") if stop_error: info = stop_error_info or self._provider_user_error(stop_error, provider=provider_used) - err_line = f"[Error] {info.message}" - current.append_final_text(err_line) + # Failure details belong to the durable status, never the + # words that are counted, copied, summarized, or exported. + current.step = info.message self._add_to_history(current) await self._save_transcript_to_db_async(current) finished_payload = session_finished_event( diff --git a/tests/core/test_openrouter_stt_retry.py b/tests/core/test_openrouter_stt_retry.py new file mode 100644 index 00000000..603355c1 --- /dev/null +++ b/tests/core/test_openrouter_stt_retry.py @@ -0,0 +1,263 @@ +import asyncio +import base64 +import io +import threading +from datetime import UTC, datetime, timedelta +from email.utils import format_datetime + +import aiohttp +import pytest +from aiohttp import web + +from src import cloud_async_stt +from src.core.provider_errors import ProviderTransportError + + +class ReplySession: + """HTTP boundary double that consumes/closes files like aiohttp does.""" + + def __init__(self, status=429, headers=None, message="Provider returned error", stalled=False): + self.status = status + self.headers = headers or {} + self.message = message + self.stalled = stalled + self.bodies = [] + self.request_times = [] + self.timeouts = [] + self.closed_responses = 0 + + def post(self, _url, **kwargs): + self.bodies.append(kwargs["data"]) + self.request_times.append(datetime.now(UTC)) + self.timeouts.append(kwargs["timeout"].total) + session = self + + class Reply: + status = session.status + headers = session.headers + + async def __aenter__(self): + kwargs["data"].read() + kwargs["data"].close() + return self + + async def __aexit__(self, *_args): + session.closed_responses += 1 + + async def text(self): + if session.stalled: + await asyncio.sleep(60) + return '{"error":{"message":"' + session.message + '"}}' + + return Reply() + + +async def transcribe(session, **kwargs): + return await cloud_async_stt.transcribe_with_openrouter_audio_transcription( + session=session, + api_key="local-test-only", + audio_source=b"retained-mp3", + filename="audio.mp3", + content_type="audio/mpeg", + language="de", + **kwargs, + ) + + +@pytest.mark.asyncio +async def test_openrouter_retries_confirmed_429_with_identical_audio_over_real_http(monkeypatch): + requests = [] + + async def transcribe(request): + requests.append(await request.json()) + if len(requests) < 3: + return web.json_response( + {"error": {"code": 429, "message": "Provider returned error"}}, + status=429, + headers={"Retry-After": "0"}, + ) + return web.json_response({"text": "Recovered dictation"}) + + app = web.Application() + app.router.add_post("/transcriptions", transcribe) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + monkeypatch.setattr( + cloud_async_stt, "OPENROUTER_STT_URL", f"http://127.0.0.1:{runner.addresses[0][1]}/transcriptions" + ) + audio = io.BytesIO(b"prefix:retained-audio") + audio.seek(7) + try: + async with aiohttp.ClientSession() as session: + result = await cloud_async_stt.transcribe_with_openrouter_audio_transcription( + session=session, + api_key="local-test-only", + audio_source=audio, + filename="audio.mp3", + content_type="audio/mpeg", + language="de", + ) + assert result == {"text": "Recovered dictation"} + assert len(requests) == 3 + assert requests[0] == requests[1] == requests[2] + assert base64.b64decode(requests[0]["input_audio"]["data"]) == b"retained-audio" + assert not audio.closed + finally: + audio.close() + await runner.cleanup() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [400, 401, 402, 403, 408, 413, 500, 503]) +async def test_openrouter_does_not_replay_other_http_failures(status): + session = ReplySession(status) + with pytest.raises(ProviderTransportError) as caught: + await transcribe(session) + assert caught.value.status == status + assert len(session.bodies) == session.closed_responses == 1 + assert session.bodies[0].closed + + +@pytest.mark.asyncio +async def test_openrouter_does_not_retry_an_explicit_credit_failure_returned_as_429(): + session = ReplySession(message="Insufficient credits", headers={"Retry-After": "0"}) + with pytest.raises(ProviderTransportError): + await transcribe(session) + assert len(session.bodies) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hint", ["60", "9999999999999999999999"]) +async def test_openrouter_never_shortens_server_wait_to_fit_retry_budget(hint): + session = ReplySession(headers={"Retry-After": hint}) + with pytest.raises(ProviderTransportError) as caught: + await transcribe(session) + assert caught.value.status == 429 + assert len(session.bodies) == session.closed_responses == 1 + + +@pytest.mark.asyncio +async def test_openrouter_repeated_429_is_bounded_to_three_attempts(): + session = ReplySession(headers={"Retry-After": "0"}) + with pytest.raises(ProviderTransportError) as caught: + await transcribe(session) + assert caught.value.status == 429 + assert len(session.bodies) == session.closed_responses == 3 + assert all(body.closed for body in session.bodies) + assert session.timeouts[0] > session.timeouts[1] > session.timeouts[2] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hint", [None, "not a date"]) +async def test_openrouter_missing_or_invalid_hint_uses_backoff(hint): + session = ReplySession(headers={"Retry-After": hint}) + with pytest.raises(ProviderTransportError): + await transcribe(session) + assert len(session.request_times) == 3 + assert (session.request_times[1] - session.request_times[0]).total_seconds() >= 0.5 + assert (session.request_times[2] - session.request_times[1]).total_seconds() >= 1.0 + + +@pytest.mark.asyncio +async def test_openrouter_honors_http_date_retry_after(): + retry_at = (datetime.now(UTC) + timedelta(seconds=2)).replace(microsecond=0) + session = ReplySession(headers={"Retry-After": format_datetime(retry_at, usegmt=True)}) + with pytest.raises(ProviderTransportError): + await transcribe(session) + assert len(session.request_times) == 3 + assert session.request_times[1] >= retry_at + + +@pytest.mark.asyncio +async def test_openrouter_cancellation_during_backoff_never_starts_another_upload(): + waiting = asyncio.Event() + session = ReplySession(headers={"Retry-After": "4"}) + + def progress(message): + if message == "Waiting before retrying transcription...": + waiting.set() + + task = asyncio.create_task(transcribe(session, on_progress=progress)) + try: + await asyncio.wait_for(waiting.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert len(session.bodies) == session.closed_responses == 1 + assert session.bodies[0].closed + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_openrouter_retry_hint_cannot_extend_the_original_timeout(): + session = ReplySession(headers={"Retry-After": "1"}) + with pytest.raises(ProviderTransportError): + await transcribe(session, timeout_secs=0.1) + assert len(session.bodies) == 1 + + +@pytest.mark.asyncio +async def test_openrouter_ambiguous_response_timeout_does_not_replay_billable_work(): + session = ReplySession(status=200, stalled=True) + with pytest.raises(TimeoutError): + await transcribe(session, timeout_secs=0.05) + assert len(session.bodies) == session.closed_responses == 1 + assert session.bodies[0].closed + + +@pytest.mark.asyncio +async def test_openrouter_non_seekable_audio_is_not_replayed(): + class OneShotAudio(io.BytesIO): + def seekable(self): + return False + + session = ReplySession(headers={"Retry-After": "0"}) + with OneShotAudio(b"retained-mp3") as audio: + with pytest.raises(ProviderTransportError): + await cloud_async_stt.transcribe_with_openrouter_audio_transcription( + session=session, + api_key="local-test-only", + audio_source=audio, + filename="audio.mp3", + content_type="audio/mpeg", + language="de", + ) + assert not audio.closed + assert len(session.bodies) == session.closed_responses == 1 + + +@pytest.mark.asyncio +async def test_openrouter_canceled_preparation_closes_its_body_without_upload(monkeypatch): + loop = asyncio.get_running_loop() + prepared = asyncio.Event() + release = threading.Event() + bodies = [] + build = cloud_async_stt._build_openrouter_stt_json_body + + def blocked_build(*args, **kwargs): + body = build(*args, **kwargs) + bodies.append(body) + loop.call_soon_threadsafe(prepared.set) + release.wait(timeout=5) + return body + + monkeypatch.setattr(cloud_async_stt, "_build_openrouter_stt_json_body", blocked_build) + session = ReplySession() + task = asyncio.create_task(transcribe(session)) + try: + await asyncio.wait_for(prepared.wait(), timeout=2) + task.cancel() + await asyncio.sleep(0) + assert not task.done() + finally: + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert len(bodies) == 1 + assert bodies[0].closed + assert not session.bodies diff --git a/tests/core/test_provider_errors.py b/tests/core/test_provider_errors.py index b3690d1a..3f0d95ed 100644 --- a/tests/core/test_provider_errors.py +++ b/tests/core/test_provider_errors.py @@ -1,3 +1,5 @@ +import json + import pytest from src.core.error_taxonomy import ErrorCategory @@ -397,3 +399,32 @@ def test_router_upstream_timeout_has_actionable_message_and_keeps_http_status_au info = provider_user_error(None, contradiction) assert info.category is ErrorCategory.AUTH_INVALID assert "timed out" not in info.message + + +@pytest.mark.parametrize("message", ["Provider returned error", "quota exceeded", "rate limit reached"]) +def test_openrouter_429_does_not_claim_insufficient_credits(message): + error = provider_transport_error( + "openrouter_stt", + "transcription", + status=429, + response_body=json.dumps({"error": {"code": 429, "message": message}}), + ) + info = provider_user_error("openrouter_stt", error) + assert "temporarily rate limited" in info.message + assert "credit" not in info.message.lower() + assert info.retryable + # Live Mic currently carries the safe message through its terminal frame. + assert provider_user_error("openrouter_stt", RuntimeError(info.message)).message == info.message + + +@pytest.mark.parametrize("status", [402, 429]) +def test_openrouter_explicit_credit_failure_is_not_retryable(status): + error = provider_transport_error( + "openrouter_stt", + "transcription", + status=status, + response_body='{"error":{"message":"Insufficient credits"}}', + ) + info = provider_user_error("openrouter_stt", error) + assert "insufficient credits" in info.message + assert not info.retryable diff --git a/tests/test_web_api_lifecycle.py b/tests/test_web_api_lifecycle.py index e25bb2ee..f8838163 100644 --- a/tests/test_web_api_lifecycle.py +++ b/tests/test_web_api_lifecycle.py @@ -3522,6 +3522,8 @@ async def _boom(): assert ctl._session_id is None assert rec in ctl._history save_mock.assert_awaited_once_with(rec) + assert rec.content_text() == "" + assert rec.step == ctl._provider_user_error(RuntimeError("boom")).message class _StopOkPipeline: @@ -3786,12 +3788,14 @@ async def stop(self, timeout_secs=None): @pytest.mark.asyncio -async def test_stop_listening_marks_failed_when_stop_raises(): +@pytest.mark.parametrize("retained_speech", ["", "Already recognized speech."]) +async def test_stop_listening_marks_failed_when_stop_raises(retained_speech, failed_session_database): loop = asyncio.get_running_loop() ctl = ScriberWebController(loop) session_id = "stop-fail-session" rec = _make_record(session_id) + rec.append_final_text(retained_speech) ctl._current = rec ctl._session_id = session_id ctl._is_listening = True @@ -3823,12 +3827,30 @@ async def test_stop_listening_marks_failed_when_stop_raises(): assert error_payloads[-1]["providerLabel"] == "Microsoft MAI Transcribe" assert error_payloads[-1]["category"] == "transient_provider" assert error_payloads[-1]["code"] == "ServiceUnavailable" - assert ( - "[Error] Microsoft MAI Transcribe is temporarily unavailable. Retry shortly; if it repeats, check the Azure region and service status." - in rec.content - ) + assert rec.content_text() == retained_speech + assert rec.step == error_payloads[-1]["message"] + saved = failed_session_database.get_transcript(rec.id) + assert saved is not None + assert saved["content"] == retained_speech + assert saved["step"] == rec.step assert ctl._status == "Error" assert rec in ctl._history + await ctl.drain_background_tasks_for_shutdown(timeout_seconds=1.0) + ctl.shutdown() + + +@pytest.fixture +def failed_session_database(monkeypatch, tmp_path): + # Other lifecycle tests temporarily rebind the database. Retire their + # memoized connections so both the worker write and this read use one DB. + web_api.db._close_all_connections() + monkeypatch.setenv("SCRIBER_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(web_api.db, "_DB_PATH", tmp_path / "transcripts.db") + web_api.db.init_database() + try: + yield web_api.db + finally: + web_api.db._close_all_connections() @pytest.mark.asyncio From d72a1d109ba2888b68d95a0994242a02cbca7e84 Mon Sep 17 00:00:00 2001 From: MyButtermilk Date: Mon, 28 Sep 2026 17:39:13 +0200 Subject: [PATCH 2/3] chore: prepare Scriber 0.5.128 release --- Frontend/package-lock.json | 4 ++-- Frontend/package.json | 2 +- src/version.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 09c898f0..33f78224 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "scriber", - "version": "0.5.127", + "version": "0.5.128", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scriber", - "version": "0.5.127", + "version": "0.5.128", "license": "MIT", "dependencies": { "@carrot-kpi/switzer-font": "^0.1.0", diff --git a/Frontend/package.json b/Frontend/package.json index c11fe79f..bceb52bf 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -1,6 +1,6 @@ { "name": "scriber", - "version": "0.5.127", + "version": "0.5.128", "type": "module", "license": "MIT", "engines": { diff --git a/src/version.py b/src/version.py index f8629340..2c726f8e 100644 --- a/src/version.py +++ b/src/version.py @@ -3,7 +3,7 @@ import os import re -__version__ = "0.5.127" +__version__ = "0.5.128" _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") From 3b46f79bc80b3ff4257a82bd18a9cf1a1ac065f1 Mon Sep 17 00:00:00 2001 From: MyButtermilk Date: Mon, 28 Sep 2026 17:49:12 +0200 Subject: [PATCH 3/3] fix: retain empty failures and exclude legacy storage errors --- Frontend/client/src/lib/request-errors.ts | 4 ++-- .../src/pages/TranscriptDetail.test.tsx | 20 +++++++++++++------ .../client/src/pages/TranscriptDetail.tsx | 3 ++- src/web_api.py | 13 ++++++------ tests/test_web_api_lifecycle.py | 16 ++++++++++++--- 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/Frontend/client/src/lib/request-errors.ts b/Frontend/client/src/lib/request-errors.ts index a99e556e..13edcb76 100644 --- a/Frontend/client/src/lib/request-errors.ts +++ b/Frontend/client/src/lib/request-errors.ts @@ -12,7 +12,7 @@ const TIMEOUT_ERROR_TOKENS = ["timeout", "timed out", "aborted", "aborterror"]; const CORS_ERROR_TOKENS = ["cors", "cross-origin"]; const INVALID_ARGUMENT_TOKENS = ["errno 22", "invalid argument"]; -const PREFIX_PATTERN = /^\[(error|timeout|download error)\]\s*/i; +const PREFIX_PATTERN = /^\[(error|timeout|download error|storage error)\]\s*/i; const STATUS_PREFIX_PATTERN = /^\d{3}:\s*/; function stripLowLevelPrefixes(rawMessage: string): string { @@ -89,7 +89,7 @@ export async function responseErrorMessage(res: Response): Promise { export function extractFailureMessage(content: string, step: string): string { const rawContent = (content || "").trim(); if (rawContent) { - const matches = Array.from(rawContent.matchAll(/\[(error|timeout|download error)\]\s*([^\n]+)/gi)); + const matches = Array.from(rawContent.matchAll(/\[(error|timeout|download error|storage error)\]\s*([^\n]+)/gi)); if (matches.length > 0) { const last = matches[matches.length - 1]; const reason = (last?.[2] || "").trim(); diff --git a/Frontend/client/src/pages/TranscriptDetail.test.tsx b/Frontend/client/src/pages/TranscriptDetail.test.tsx index 15d936ab..58fb22d5 100644 --- a/Frontend/client/src/pages/TranscriptDetail.test.tsx +++ b/Frontend/client/src/pages/TranscriptDetail.test.tsx @@ -15,13 +15,13 @@ vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false })); const rateLimitMessage = "Microsoft MAI Transcribe via OpenRouter is temporarily rate limited (HTTP 429). Wait briefly or switch transcription provider."; -function mount(content = "", step = rateLimitMessage, summary = "") { +function mount(content = "", step = rateLimitMessage, summary = "", type: TranscriptDetailResponse["type"] = "mic") { const record: TranscriptDetailResponse = { id: "failed-mic", title: "Live Mic", date: "Today", duration: "00:03", - type: "mic", + type, status: "failed", content, step, @@ -44,7 +44,7 @@ function mount(content = "", step = rateLimitMessage, summary = "") { ); } -describe("failed Live Mic transcript", () => { +describe("failed transcript", () => { beforeEach(() => { window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "en"); }); @@ -68,9 +68,17 @@ describe("failed Live Mic transcript", () => { expect(screen.getByRole("button", { name: "Export" })).toBeEnabled(); }); - it("does not count or offer to copy a legacy error-only transcript", async () => { - mount(`[Error] ${rateLimitMessage}`, "Transcribing...", "Earlier summary"); - expect(await screen.findByRole("alert")).toHaveTextContent(rateLimitMessage); + it.each([ + ["Error", "mic"], + ["Timeout", "mic"], + ["Download error", "youtube"], + ["Storage error", "file"], + ["Storage error", "youtube"], + ] as const)("excludes legacy %s content from a %s transcript", async (prefix, type) => { + mount(`[${prefix}] ${rateLimitMessage}`, "Transcribing...", "Earlier summary", type); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent(rateLimitMessage); + expect(alert).not.toHaveTextContent(`[${prefix}]`); expect(screen.queryByText(/\d+ words/)).toBeNull(); expect(screen.queryByRole("button", { name: "Copy transcript" })).toBeNull(); expect(screen.getByText("No transcript text captured.")).toBeInTheDocument(); diff --git a/Frontend/client/src/pages/TranscriptDetail.tsx b/Frontend/client/src/pages/TranscriptDetail.tsx index ea963bc1..081b31ca 100644 --- a/Frontend/client/src/pages/TranscriptDetail.tsx +++ b/Frontend/client/src/pages/TranscriptDetail.tsx @@ -439,7 +439,8 @@ export default function TranscriptDetail() { const hasSummary = summarySource.length > 0; const isFailedTranscript = transcript.status === "failed"; const failedContentLooksLikeErrorOnly = - isFailedTranscript && /^\[(error|timeout|download error)\]/i.test(String(transcript.content || "").trim()); + isFailedTranscript && + /^\[(error|timeout|download error|storage error)\]/i.test(String(transcript.content || "").trim()); const transcriptContent = failedContentLooksLikeErrorOnly ? "" : String(transcript.content || ""); const hasExportableContent = !failedContentLooksLikeErrorOnly && Boolean(transcriptContent.trim() || hasSummary); const transcriptWordCount = useMemo( diff --git a/src/web_api.py b/src/web_api.py index e5d0da90..f332e652 100644 --- a/src/web_api.py +++ b/src/web_api.py @@ -9259,13 +9259,12 @@ async def _broadcast_error(payload: dict[str, Any]): self._current = None if failed_current: failed_current.step = user_msg - if failed_current.content_text().strip() or info.category is not ErrorCategory.CONFIG_INVALID: - self._add_to_history(failed_current) - self._schedule_transcript_save(failed_current) - self._spawn_detached_threadsafe( - lambda: self._broadcast_history_updated(record=failed_current, reason="pipeline_failed"), - name="pipeline_failure_history_broadcast", - ) + self._add_to_history(failed_current) + self._schedule_transcript_save(failed_current) + self._spawn_detached_threadsafe( + lambda: self._broadcast_history_updated(record=failed_current, reason="pipeline_failed"), + name="pipeline_failure_history_broadcast", + ) finally: # Schedule safe cleanup on the event loop self._spawn_detached_threadsafe( diff --git a/tests/test_web_api_lifecycle.py b/tests/test_web_api_lifecycle.py index f8838163..09126b58 100644 --- a/tests/test_web_api_lifecycle.py +++ b/tests/test_web_api_lifecycle.py @@ -11,6 +11,7 @@ import pytest from src import web_api +from src.core.provider_errors import provider_transport_error from src.web_api import ScriberWebController, TranscriptRecord @@ -3491,7 +3492,14 @@ async def test_audio_level_updates_overlay_without_ws_clients(): @pytest.mark.asyncio -async def test_on_pipeline_done_persists_failed_live_session(): +@pytest.mark.parametrize( + "failure", + [ + RuntimeError("boom"), + provider_transport_error("openrouter_stt", "transcription", status=400, response_body="Invalid request"), + ], +) +async def test_on_pipeline_done_persists_failed_live_session(failure): loop = asyncio.get_running_loop() ctl = ScriberWebController(loop) @@ -3500,9 +3508,11 @@ async def test_on_pipeline_done_persists_failed_live_session(): ctl._current = rec ctl._session_id = session_id ctl._is_listening = True + ctl._active_provider = "openrouter_stt" + expected_message = ctl._provider_user_error(failure, provider="openrouter_stt").message async def _boom(): - raise RuntimeError("boom") + raise failure task = asyncio.create_task(_boom()) await asyncio.sleep(0) @@ -3523,7 +3533,7 @@ async def _boom(): assert rec in ctl._history save_mock.assert_awaited_once_with(rec) assert rec.content_text() == "" - assert rec.step == ctl._provider_user_error(RuntimeError("boom")).message + assert rec.step == expected_message class _StopOkPipeline: