use std::time::Duration;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TalkError {
#[error("Configuration error: {0}")]
Config(String),
#[error("Audio error: {0}")]
Audio(String),
#[error("Transcription error: {0}")]
Transcription(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Clipboard error: {0}")]
Clipboard(String),
#[error("Session error: {0}")]
Session(String),
#[error("Transcription already in progress")]
TranscriptInProgress,
#[error("Transcription not cached (API call forbidden)")]
CacheOnly,
#[error("Transcription for this model already in progress")]
ModelInProgress,
#[error(transparent)]
Pipeline(Box<PipelineFailure>),
}
impl From<PipelineFailure> for TalkError {
fn from(pf: PipelineFailure) -> Self {
TalkError::Pipeline(Box::new(pf))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PipelinePhase {
Validate,
Request,
}
impl PipelinePhase {
fn verb(self) -> &'static str {
match self {
Self::Validate => "validation",
Self::Request => "transcription request",
}
}
}
#[derive(Debug, Clone)]
pub struct TimerLabel {
pub name: String,
pub budget: String,
}
impl TimerLabel {
pub fn from_duration(name: impl Into<String>, budget: Duration) -> Self {
Self {
name: name.into(),
budget: fmt_duration(budget),
}
}
}
fn fmt_duration(d: Duration) -> String {
if d.subsec_nanos() == 0 {
format!("{}s", d.as_secs())
} else {
format!("{:.3}s", d.as_secs_f64())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkKind {
Connect,
WallClock,
KernelTcp,
Other,
}
#[derive(Error, Debug)]
pub enum PipelineFailureKind {
#[error("{kind:?} timeout")]
Network {
kind: NetworkKind,
timer: Option<TimerLabel>,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("HTTP {status}: {body}")]
HttpStatus { status: u16, body: String },
#[error("model '{model}' not in available list")]
ModelRejected {
model: String,
suggestions: Vec<String>,
},
#[error("could not parse response: {0}")]
Decode(String),
}
#[derive(Error, Debug)]
pub struct PipelineFailure {
pub provider: String,
pub phase: PipelinePhase,
pub attempts: u32,
pub max_attempts: u32,
pub url: String,
#[source]
pub kind: PipelineFailureKind,
}
impl PipelineFailure {
pub fn new(
provider: impl Into<String>,
phase: PipelinePhase,
attempts: u32,
max_attempts: u32,
url: impl Into<String>,
kind: PipelineFailureKind,
) -> Self {
Self {
provider: provider.into(),
phase,
attempts,
max_attempts,
url: url.into(),
kind,
}
}
}
impl std::fmt::Display for PipelineFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let provider_display = &self.provider;
match &self.kind {
PipelineFailureKind::Network {
kind: _,
timer,
source,
} => {
write!(
f,
"{} model {} failed [",
provider_display,
self.phase.verb(),
)?;
if let Some(t) = timer {
write!(f, "name={}, budget={}, ", t.name, t.budget)?;
}
write!(
f,
"url={}] (after {}/{} attempts)",
self.url, self.attempts, self.max_attempts
)?;
render_dedup_source_chain(f, source.as_ref(), timer.as_ref(), &self.url)?;
Ok(())
}
PipelineFailureKind::HttpStatus { status, body } => {
let body_trimmed = body.trim();
let body_short = if body_trimmed.len() > 200 {
format!("{}…", &body_trimmed[..200])
} else {
body_trimmed.to_string()
};
let server_retryable = *status == 429 || (500..600).contains(status);
let retry_note: &'static str = if server_retryable {
if self.attempts >= self.max_attempts {
" — server-retry budget exhausted"
} else {
""
}
} else if (400..500).contains(status) {
" — 4xx permanent, no retry"
} else {
""
};
write!(
f,
"{} model {} failed [status={}, url={}] (after {}/{} attempts{})",
provider_display,
self.phase.verb(),
status,
self.url,
self.attempts,
self.max_attempts,
retry_note,
)?;
if !body_short.is_empty() {
write!(f, ": {}", body_short)?;
}
Ok(())
}
PipelineFailureKind::ModelRejected { model, suggestions } => {
let phase_note = match self.phase {
PipelinePhase::Validate => " (preflight to /v1/models)",
PipelinePhase::Request => "",
};
write!(
f,
"{} model '{}' not found{}",
provider_display, model, phase_note,
)?;
if !suggestions.is_empty() {
write!(f, ". Available: {}", suggestions.join(", "))?;
}
Ok(())
}
PipelineFailureKind::Decode(msg) => {
write!(
f,
"{} model {} failed [decode, url={}] (after {}/{} attempts): {}",
provider_display,
self.phase.verb(),
self.url,
self.attempts,
self.max_attempts,
msg,
)
}
}
}
}
fn render_dedup_source_chain(
f: &mut std::fmt::Formatter<'_>,
err: &(dyn std::error::Error + 'static),
timer: Option<&TimerLabel>,
url: &str,
) -> std::fmt::Result {
let mut shown: Vec<String> = Vec::new();
let mut current: Option<&dyn std::error::Error> = Some(err);
while let Some(e) = current {
let msg = e.to_string();
let lower = msg.to_lowercase();
let redundant = is_layer_redundant(&lower, timer, url)
|| shown
.iter()
.any(|prev| prev.contains(&msg) || msg.contains(prev.as_str()));
if !redundant {
write!(f, " -> {}", msg)?;
shown.push(msg);
}
current = e.source();
}
Ok(())
}
fn is_layer_redundant(lower: &str, timer: Option<&TimerLabel>, url: &str) -> bool {
if lower.contains("client error (connect)") {
return true;
}
if lower == "operation timed out"
|| lower == "deadline has elapsed"
|| lower == "request timeout"
|| lower == "client error (timeout)"
{
return true;
}
if let Some(t) = timer {
if lower == t.name {
return true;
}
}
let url_lower = url.to_lowercase();
if !url_lower.is_empty()
&& lower.contains(&format!("error sending request for url ({})", url_lower))
{
return true;
}
if !url_lower.is_empty() && lower == format!("error sending request for url ({})", url_lower) {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_error_display() {
let err = TalkError::Config("missing field".to_string());
assert_eq!(err.to_string(), "Configuration error: missing field");
}
#[test]
fn test_io_error_from() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err: TalkError = io_err.into();
assert!(matches!(err, TalkError::Io(_)));
}
#[test]
fn pipeline_failure_network_renders_cleanly() {
let inner = std::io::Error::new(std::io::ErrorKind::TimedOut, "operation timed out");
let pf = PipelineFailure::new(
"OpenAI",
PipelinePhase::Validate,
5,
5,
"https://api.openai.com/v1/models",
PipelineFailureKind::Network {
kind: NetworkKind::Connect,
timer: Some(TimerLabel::from_duration(
"connect_timeout",
Duration::from_secs(2),
)),
source: Box::new(inner),
},
);
let s = pf.to_string();
assert!(
s.starts_with("OpenAI model validation failed ["),
"got: {}",
s
);
assert!(s.contains("name=connect_timeout"), "got: {}", s);
assert!(s.contains("budget=2s"), "got: {}", s);
assert!(
s.contains("url=https://api.openai.com/v1/models"),
"got: {}",
s
);
assert!(s.contains("(after 5/5 attempts)"), "got: {}", s);
}
#[test]
fn pipeline_failure_dedups_operation_timed_out() {
let inner = std::io::Error::new(std::io::ErrorKind::TimedOut, "operation timed out");
let pf = PipelineFailure::new(
"OpenAI",
PipelinePhase::Validate,
5,
5,
"https://x",
PipelineFailureKind::Network {
kind: NetworkKind::Connect,
timer: Some(TimerLabel::from_duration(
"connect_timeout",
Duration::from_secs(2),
)),
source: Box::new(inner),
},
);
let s = pf.to_string();
assert!(
!s.contains(" -> operation timed out"),
"redundant layer must be skipped, got: {}",
s
);
}
#[test]
fn pipeline_failure_keeps_econnrefused_layer() {
let inner = std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"Connection refused (os error 111)",
);
let pf = PipelineFailure::new(
"Mistral",
PipelinePhase::Request,
1,
6,
"https://x",
PipelineFailureKind::Network {
kind: NetworkKind::Connect,
timer: Some(TimerLabel::from_duration(
"connect_timeout",
Duration::from_secs(2),
)),
source: Box::new(inner),
},
);
let s = pf.to_string();
assert!(
s.contains("Connection refused (os error 111)"),
"ECONNREFUSED layer must be kept, got: {}",
s
);
}
#[test]
fn pipeline_failure_dedups_reqwest_url_restatement() {
let url = "https://mistral.vps-03.0k.io/v1/audio/transcriptions";
let reqwest_layer =
std::io::Error::other(format!("error sending request for url ({})", url));
let pf = PipelineFailure::new(
"Mistral",
PipelinePhase::Request,
5,
5,
url,
PipelineFailureKind::Network {
kind: NetworkKind::Connect,
timer: Some(TimerLabel::from_duration(
"connect_timeout",
Duration::from_secs(2),
)),
source: Box::new(reqwest_layer),
},
);
let s = pf.to_string();
let url_occurrences = s.matches(url).count();
assert_eq!(
url_occurrences, 1,
"URL must appear exactly once (Step 13 dedup); got {} occurrences in {}",
url_occurrences, s
);
assert!(
!s.contains(" -> error sending request for url"),
"reqwest URL-restatement layer must be dropped; got: {}",
s
);
}
#[test]
fn pipeline_failure_model_rejected_renders_with_suggestions() {
let pf = PipelineFailure::new(
"Mistral",
PipelinePhase::Validate,
1,
5,
"https://x",
PipelineFailureKind::ModelRejected {
model: "voxtral-mini-9999".into(),
suggestions: vec!["voxtral-mini-2507".into(), "voxtral-mini-2602".into()],
},
);
let s = pf.to_string();
assert!(
s.starts_with("Mistral model 'voxtral-mini-9999' not found (preflight to /v1/models)"),
"got: {}",
s
);
assert!(
s.contains("Available: voxtral-mini-2507, voxtral-mini-2602"),
"got: {}",
s
);
assert!(!s.contains("Configuration error:"), "got: {}", s);
}
#[test]
fn pipeline_failure_model_rejected_no_suggestions() {
let pf = PipelineFailure::new(
"OpenAI",
PipelinePhase::Validate,
1,
5,
"https://x",
PipelineFailureKind::ModelRejected {
model: "ghost-model".into(),
suggestions: vec![],
},
);
let s = pf.to_string();
assert!(
s.contains("model 'ghost-model' not found (preflight to /v1/models)"),
"got: {}",
s
);
assert!(!s.contains("Available:"), "got: {}", s);
}
#[test]
fn pipeline_failure_http_status_renders() {
let pf = PipelineFailure::new(
"OpenAI",
PipelinePhase::Validate,
1,
5,
"https://api.openai.com/v1/models",
PipelineFailureKind::HttpStatus {
status: 401,
body: "Unauthorized".into(),
},
);
let s = pf.to_string();
assert!(s.contains("status=401"), "got: {}", s);
assert!(s.contains("Unauthorized"), "got: {}", s);
assert!(s.contains("(after 1/5 attempts"), "got: {}", s);
assert!(s.contains("4xx permanent, no retry"), "got: {}", s);
}
#[test]
fn pipeline_failure_http_status_500_annotates_when_exhausted() {
let pf = PipelineFailure::new(
"OpenAI",
PipelinePhase::Request,
3,
3,
"https://x",
PipelineFailureKind::HttpStatus {
status: 503,
body: "Service Unavailable".into(),
},
);
let s = pf.to_string();
assert!(s.contains("status=503"), "got: {}", s);
assert!(
s.contains("server-retry budget exhausted"),
"exhausted 5xx must annotate; got: {}",
s
);
let mid = PipelineFailure::new(
"OpenAI",
PipelinePhase::Request,
1,
3,
"https://x",
PipelineFailureKind::HttpStatus {
status: 503,
body: "Service Unavailable".into(),
},
);
let s = mid.to_string();
assert!(
!s.contains("server-retry budget exhausted"),
"mid-retry 5xx must not annotate yet; got: {}",
s
);
}
#[test]
fn pipeline_failure_http_status_429_annotates_like_5xx() {
let exhausted = PipelineFailure::new(
"Mistral",
PipelinePhase::Request,
7,
7,
"https://x",
PipelineFailureKind::HttpStatus {
status: 429,
body: "backend_out_of_capacity".into(),
},
);
let s = exhausted.to_string();
assert!(s.contains("server-retry budget exhausted"), "got: {}", s);
assert!(!s.contains("4xx permanent"), "got: {}", s);
let mid = PipelineFailure::new(
"Mistral",
PipelinePhase::Request,
2,
7,
"https://x",
PipelineFailureKind::HttpStatus {
status: 429,
body: "backend_out_of_capacity".into(),
},
);
let s = mid.to_string();
assert!(!s.contains("server-retry budget exhausted"), "got: {}", s);
assert!(!s.contains("4xx permanent"), "got: {}", s);
}
#[test]
fn talk_error_pipeline_display_is_transparent() {
let pf = PipelineFailure::new(
"OpenAI",
PipelinePhase::Validate,
1,
5,
"https://x",
PipelineFailureKind::ModelRejected {
model: "ghost".into(),
suggestions: vec![],
},
);
let pf_string = pf.to_string();
let te: TalkError = pf.into();
assert_eq!(
te.to_string(),
pf_string,
"TalkError::Pipeline must be transparent"
);
}
#[test]
fn timer_label_formats_duration_compactly() {
let t = TimerLabel::from_duration("connect_timeout", Duration::from_secs(2));
assert_eq!(t.budget, "2s");
let t = TimerLabel::from_duration("request_wall_clock", Duration::from_millis(50));
assert_eq!(t.budget, "0.050s");
}
}