use std::path::Path; use std::time::Duration; use doctate_common::join_url; use reqwest::multipart::{Form, Part}; use tracing::debug; #[derive(Debug)] pub enum WhisperError { Io(std::io::Error), Http(reqwest::Error), Status { status: u16, body: String }, } impl std::fmt::Display for WhisperError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "whisper io error: {e}"), Self::Http(e) => write!(f, "whisper http error: {e}"), Self::Status { status, body } => { write!(f, "whisper returned {status}: {body}") } } } } impl std::error::Error for WhisperError {} impl WhisperError { /// Classify this error as transient (retry will likely succeed later) or /// permanent (retrying the same input will hit the same wall). /// /// Rationale: the transcribe worker uses this to decide whether to leave /// the `.m4a` in place (page-load heal re-enqueues it) or rename it to /// `.m4a.failed` (terminal; only a manual reset un-fails it). /// /// - `Http`: reqwest wraps all transport issues (DNS, connect, TLS, read, /// timeout, mid-flight reset) — every one of these is a candidate for /// „Minerva is back in a minute", so treat as transient. /// - `Status`: 5xx is server-side trouble; 408 (Request Timeout) and 429 /// (Too Many Requests) are explicit retry signals. Everything else in /// the 4xx range (400 Bad Request, 415 Unsupported Media, ...) means /// the payload itself is the problem and replays will fail identically. /// - `Io`: local filesystem error while reading the audio file. Either /// the file is gone or the disk is misbehaving; a retry of the same /// path is not the right response. pub fn is_transient(&self) -> bool { match self { Self::Http(_) => true, Self::Status { status, .. } => { *status == 408 || *status == 429 || (500..=599).contains(status) } Self::Io(_) => false, } } } /// POST the audio file to whisper-asr-webservice and return the transcript text. /// /// `output=txt` → plain-text response. `language` pins the language so the /// model skips detection (defaults to `de` when the caller passes `None` or /// an empty string). We send AAC-in-mp4 and let the service decode it /// internally (the default `encode=true`) — passing `encode=false` would /// tell the service "this is already raw PCM", which ours isn't. pub async fn transcribe( client: &reqwest::Client, whisper_url: &str, audio: &Path, timeout: Duration, language: Option<&str>, ) -> Result { let filename = audio .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "audio.m4a".to_string()); let bytes = tokio::fs::read(audio).await.map_err(WhisperError::Io)?; let part = Part::bytes(bytes) .file_name(filename) .mime_str("audio/mp4") .map_err(WhisperError::Http)?; let form = Form::new().part("audio_file", part); let language = language.filter(|s| !s.is_empty()).unwrap_or("de"); let url = format!( "{}?output=txt&language={language}", join_url(whisper_url, "/asr") ); debug!(%url, "posting to whisper"); let response = client .post(&url) .timeout(timeout) .multipart(form) .send() .await .map_err(WhisperError::Http)?; let status = response.status(); let body = response.text().await.map_err(WhisperError::Http)?; if !status.is_success() { return Err(WhisperError::Status { status: status.as_u16(), body, }); } Ok(body) } #[cfg(test)] mod tests { use super::*; /// Table-driven classification test. `Http` variant is not publicly /// constructible (reqwest owns its error ctor), so the transient-Http /// path is covered by the integration test in /// `tests/transient_failure_retries_test.rs`. Same code path is exercised /// here via a 503 Status, which also routes to `is_transient == true`. #[test] fn is_transient_classifies_status_codes() { let cases: &[(u16, bool)] = &[ (400, false), (401, false), (403, false), (404, false), (408, true), (415, false), (422, false), (429, true), (500, true), (502, true), (503, true), (504, true), ]; for &(status, expected) in cases { let err = WhisperError::Status { status, body: String::new(), }; assert_eq!( err.is_transient(), expected, "status {status} expected is_transient={expected}" ); } } #[test] fn is_transient_io_is_permanent() { let err = WhisperError::Io(std::io::Error::other("disk gone")); assert!(!err.is_transient()); } }