Skip to main content

ferrin_core/
error.rs

1//! Core error type: the single `#[non_exhaustive]` enum applications see.
2//!
3//! Payloads that would push the enum past 128 bytes are boxed
4//! (`Provider`, `Download`, `InvalidToolInput`, `NoObjectGenerated`,
5//! `NoSuchProvider`); see the error-model design document.
6
7use std::time::Duration;
8
9use ferrin_message::Message;
10use ferrin_spec::ApprovalId;
11use ferrin_spec::FinishReason;
12use ferrin_spec::ProviderId;
13use ferrin_spec::ResponseMetadata;
14use ferrin_spec::ToolCallId;
15use ferrin_spec::ToolName;
16use ferrin_spec::Usage;
17use ferrin_spec::error::ModelKind;
18use ferrin_spec::error::ProviderError;
19use ferrin_spec::language_model::StreamError;
20use http::StatusCode;
21use serde::Deserialize;
22use serde::Serialize;
23use url::Url;
24
25use crate::timeout::TimeoutScope;
26
27/// A boxed error.
28pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
29
30/// Errors returned by the core API.
31#[derive(Debug, thiserror::Error)]
32#[non_exhaustive]
33pub enum Error {
34    /// A provider adapter failed.
35    #[error(transparent)]
36    Provider(Box<ProviderError>),
37
38    /// Retries were exhausted or stopped early.
39    #[error("retries exhausted after {attempts} attempts ({reason})")]
40    Retry {
41        /// Why retrying stopped.
42        reason: RetryReason,
43        /// Number of attempts made.
44        attempts: u32,
45        /// Errors of every attempt, oldest first.
46        errors: Vec<ProviderError>,
47    },
48
49    /// A configured timeout elapsed.
50    #[error("timeout ({scope}) after {elapsed:?}")]
51    Timeout {
52        /// Which timeout fired.
53        scope: TimeoutScope,
54        /// Time elapsed when it fired.
55        elapsed: Duration,
56    },
57
58    /// The caller cancelled the operation.
59    #[error("operation cancelled")]
60    Cancelled,
61
62    /// A call setting is invalid.
63    #[error("invalid argument `{argument}`: {message}")]
64    InvalidArgument {
65        /// Name of the offending argument.
66        argument: String,
67        /// What is wrong with it.
68        message: String,
69    },
70
71    /// The prompt is invalid (empty, both `prompt` and `messages`, ...).
72    #[error("invalid prompt: {message}")]
73    InvalidPrompt {
74        /// What is wrong with it.
75        message: String,
76    },
77
78    /// A message could not be converted to the specification prompt.
79    #[error("message conversion failed: {message}")]
80    MessageConversion {
81        /// What is wrong.
82        message: String,
83        /// The message concerned.
84        original_message: Box<Message>,
85    },
86
87    /// A URL in the prompt could not be downloaded.
88    #[error("download failed for {}", .0.url)]
89    Download(#[source] Box<DownloadDetails>),
90
91    /// Inline data content (base64, data URL) is invalid.
92    #[error("invalid data content: {message}")]
93    InvalidDataContent {
94        /// What is wrong.
95        message: String,
96        /// Underlying cause.
97        #[source]
98        cause: Option<BoxError>,
99    },
100
101    /// The model called a tool that is not in the tool set.
102    #[error("no such tool `{tool_name}`")]
103    NoSuchTool {
104        /// The unknown name.
105        tool_name: ToolName,
106        /// Names the model could have used.
107        available_tools: Vec<ToolName>,
108    },
109
110    /// The model produced input that does not match the tool schema.
111    #[error("invalid input for tool `{}`", .0.tool_name)]
112    InvalidToolInput(#[source] Box<InvalidToolInputDetails>),
113
114    /// The tool call repair function failed.
115    #[error("tool call repair failed")]
116    ToolCallRepair {
117        /// The error the repair function was asked to fix.
118        original: Box<Error>,
119        /// The repair function's own error.
120        #[source]
121        cause: BoxError,
122    },
123
124    /// `tool_choice` required one tool but the model called another.
125    #[error("tool choice violated: expected `{expected}`, got `{actual}`")]
126    ToolChoiceViolation {
127        /// The required tool.
128        expected: ToolName,
129        /// The tool actually called.
130        actual: ToolName,
131    },
132
133    /// An approval response refers to a tool call missing from the history.
134    #[error("tool call `{tool_call_id}` not found for approval `{approval_id}`")]
135    ToolCallNotFoundForApproval {
136        /// The referenced tool call.
137        tool_call_id: ToolCallId,
138        /// The approval concerned.
139        approval_id: ApprovalId,
140    },
141
142    /// The model finished without the tool call that the tool choice
143    /// required.
144    #[error("tool choice not satisfied: {}", .expected.as_ref().map_or_else(|| "a tool call was required".to_owned(), |name| format!("expected a call to `{name}`")))]
145    ToolChoiceNotSatisfied {
146        /// The required tool, when the choice named one.
147        expected: Option<ToolName>,
148    },
149
150    /// An approval response is invalid (unknown request, bad signature).
151    #[error("invalid tool approval `{approval_id}`: {message}")]
152    InvalidToolApproval {
153        /// The approval concerned.
154        approval_id: ApprovalId,
155        /// What is wrong.
156        message: String,
157    },
158
159    /// Structured output could not be parsed or validated.
160    #[error("no structured output generated: {}", .0.message)]
161    NoObjectGenerated(#[source] Box<NoObjectGeneratedDetails>),
162
163    /// The last step did not qualify for structured output parsing.
164    #[error("no output generated")]
165    NoOutputGenerated,
166
167    /// Every image call returned no image.
168    #[error("no image generated")]
169    NoImageGenerated {
170        /// Responses of the attempted calls.
171        responses: Vec<ResponseMetadata>,
172    },
173
174    /// The speech model returned no audio.
175    #[error("no speech generated")]
176    NoSpeechGenerated {
177        /// Responses of the attempted calls.
178        responses: Vec<ResponseMetadata>,
179    },
180
181    /// The transcription model returned no text.
182    #[error("no transcript generated")]
183    NoTranscriptGenerated {
184        /// Responses of the attempted calls.
185        responses: Vec<ResponseMetadata>,
186    },
187
188    /// The speech translation stream produced no translated text or audio.
189    #[error("no translation generated")]
190    NoTranslationGenerated {
191        /// Response metadata accumulated before the stream ended.
192        response: Box<ResponseMetadata>,
193    },
194
195    /// Every video call returned no video.
196    #[error("no video generated")]
197    NoVideoGenerated {
198        /// Responses of the attempted calls.
199        responses: Vec<ResponseMetadata>,
200    },
201
202    /// The registry has no provider with the requested id.
203    #[error("no such provider `{}`", .0.provider_id)]
204    NoSuchProvider(Box<NoSuchProviderDetails>),
205
206    /// A `provider:model` string was used without a default registry.
207    #[error("no default registry configured for model id `{model_id}`")]
208    NoDefaultRegistry {
209        /// The unresolved id.
210        model_id: String,
211    },
212
213    /// The provider stream violated the specification contract.
214    #[error("invalid stream part: {message}")]
215    InvalidStreamPart {
216        /// What is wrong.
217        message: String,
218    },
219
220    /// The provider stream reported an error part.
221    #[error("stream error: {}", .0.message)]
222    Stream(Box<StreamError>),
223
224    /// An MCP error (type-erased: the core does not depend on the MCP crate).
225    #[error(transparent)]
226    Mcp(BoxError),
227
228    /// Any other error.
229    #[error(transparent)]
230    Other(BoxError),
231}
232
233/// Why a retry loop stopped.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "kebab-case")]
236pub enum RetryReason {
237    /// The last attempt failed and no retries were left.
238    MaxRetriesExceeded,
239    /// A non-retryable error occurred after at least one retry.
240    ErrorNotRetryable,
241    /// The cancellation token fired while waiting to retry.
242    Abort,
243}
244
245impl std::fmt::Display for RetryReason {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        f.write_str(match self {
248            Self::MaxRetriesExceeded => "max retries exceeded",
249            Self::ErrorNotRetryable => "error not retryable",
250            Self::Abort => "aborted",
251        })
252    }
253}
254
255/// Payload of [`Error::Download`].
256#[derive(Debug, thiserror::Error)]
257#[error("download of {url} failed")]
258pub struct DownloadDetails {
259    /// The URL.
260    pub url: Url,
261    /// HTTP status when the server answered.
262    pub status_code: Option<StatusCode>,
263    /// Underlying cause.
264    #[source]
265    pub cause: Option<BoxError>,
266}
267
268/// Payload of [`Error::InvalidToolInput`].
269#[derive(Debug, thiserror::Error)]
270#[error("invalid input for tool `{tool_name}`: {tool_input}")]
271pub struct InvalidToolInputDetails {
272    /// The tool.
273    pub tool_name: ToolName,
274    /// The raw input text.
275    pub tool_input: String,
276    /// The parse or validation error.
277    #[source]
278    pub cause: BoxError,
279}
280
281/// Payload of [`Error::NoObjectGenerated`].
282#[derive(Debug, thiserror::Error)]
283#[error("{message}")]
284pub struct NoObjectGeneratedDetails {
285    /// Why parsing failed.
286    pub message: String,
287    /// The text the model produced, if any.
288    pub text: Option<String>,
289    /// Response metadata of the last step.
290    pub response: ResponseMetadata,
291    /// Usage of the last step.
292    pub usage: Usage,
293    /// Finish reason of the last step.
294    pub finish_reason: FinishReason,
295    /// Underlying cause.
296    #[source]
297    pub cause: Option<BoxError>,
298}
299
300/// Payload of [`Error::NoSuchProvider`].
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct NoSuchProviderDetails {
303    /// The requested provider id.
304    pub provider_id: ProviderId,
305    /// Providers the registry knows.
306    pub available_providers: Vec<ProviderId>,
307    /// The full model id that was requested.
308    pub model_id: String,
309    /// The kind of model requested.
310    pub model_kind: ModelKind,
311}
312
313/// Low-cardinality error category for logs and telemetry.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
315#[serde(rename_all = "kebab-case")]
316#[non_exhaustive]
317pub enum ErrorKind {
318    /// Provider adapter failure.
319    Provider,
320    /// Retries exhausted.
321    Retry,
322    /// Timeout.
323    Timeout,
324    /// Cancelled.
325    Cancelled,
326    /// Invalid input (arguments, prompt, data content, stream contract).
327    InvalidInput,
328    /// Tool-related failure (unknown tool, invalid input, approval).
329    Tool,
330    /// Output-related failure (no object/output/image/...).
331    Output,
332    /// Missing provider or registry.
333    NotFound,
334    /// MCP failure.
335    Mcp,
336    /// Anything else.
337    Other,
338}
339
340impl ErrorKind {
341    /// Returns the kebab-case name.
342    #[must_use]
343    pub fn as_str(self) -> &'static str {
344        match self {
345            Self::Provider => "provider",
346            Self::Retry => "retry",
347            Self::Timeout => "timeout",
348            Self::Cancelled => "cancelled",
349            Self::InvalidInput => "invalid-input",
350            Self::Tool => "tool",
351            Self::Output => "output",
352            Self::NotFound => "not-found",
353            Self::Mcp => "mcp",
354            Self::Other => "other",
355        }
356    }
357}
358
359impl std::fmt::Display for ErrorKind {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        f.write_str(self.as_str())
362    }
363}
364
365impl Error {
366    /// Creates an [`Error::InvalidArgument`].
367    #[must_use]
368    pub fn invalid_argument(argument: impl Into<String>, message: impl Into<String>) -> Self {
369        Self::InvalidArgument {
370            argument: argument.into(),
371            message: message.into(),
372        }
373    }
374
375    /// Creates an [`Error::InvalidPrompt`].
376    #[must_use]
377    pub fn invalid_prompt(message: impl Into<String>) -> Self {
378        Self::InvalidPrompt {
379            message: message.into(),
380        }
381    }
382
383    /// Creates an [`Error::InvalidDataContent`].
384    #[must_use]
385    pub fn invalid_data_content(message: impl Into<String>, cause: Option<BoxError>) -> Self {
386        Self::InvalidDataContent {
387            message: message.into(),
388            cause,
389        }
390    }
391
392    /// Creates an [`Error::Download`].
393    #[must_use]
394    pub fn download(url: Url, status_code: Option<StatusCode>, cause: Option<BoxError>) -> Self {
395        Self::Download(Box::new(DownloadDetails {
396            url,
397            status_code,
398            cause,
399        }))
400    }
401
402    /// Creates an [`Error::NoSuchTool`].
403    #[must_use]
404    pub fn no_such_tool(tool_name: impl Into<ToolName>, available_tools: Vec<ToolName>) -> Self {
405        Self::NoSuchTool {
406            tool_name: tool_name.into(),
407            available_tools,
408        }
409    }
410
411    /// Creates an [`Error::InvalidToolInput`].
412    #[must_use]
413    pub fn invalid_tool_input(
414        tool_name: impl Into<ToolName>,
415        tool_input: impl Into<String>,
416        cause: impl Into<BoxError>,
417    ) -> Self {
418        Self::InvalidToolInput(Box::new(InvalidToolInputDetails {
419            tool_name: tool_name.into(),
420            tool_input: tool_input.into(),
421            cause: cause.into(),
422        }))
423    }
424
425    /// Creates an [`Error::NoObjectGenerated`].
426    #[must_use]
427    pub fn no_object_generated(details: NoObjectGeneratedDetails) -> Self {
428        Self::NoObjectGenerated(Box::new(details))
429    }
430
431    /// Creates an [`Error::NoSuchProvider`].
432    #[must_use]
433    pub fn no_such_provider(details: NoSuchProviderDetails) -> Self {
434        Self::NoSuchProvider(Box::new(details))
435    }
436
437    /// Creates an [`Error::InvalidStreamPart`].
438    #[must_use]
439    pub fn invalid_stream_part(message: impl Into<String>) -> Self {
440        Self::InvalidStreamPart {
441            message: message.into(),
442        }
443    }
444
445    /// Wraps a stream error part as [`Error::Stream`].
446    #[must_use]
447    pub fn stream(error: StreamError) -> Self {
448        Self::Stream(Box::new(error))
449    }
450
451    /// Wraps any error as [`Error::Other`].
452    #[must_use]
453    pub fn other(error: impl std::error::Error + Send + Sync + 'static) -> Self {
454        Self::Other(Box::new(error))
455    }
456
457    /// Wraps a message as [`Error::Other`].
458    #[must_use]
459    pub fn message(message: impl Into<String>) -> Self {
460        Self::Other(message.into().into())
461    }
462
463    /// Returns `true` when retrying the operation may succeed.
464    #[must_use]
465    pub fn is_retryable(&self) -> bool {
466        match self {
467            Self::Provider(error) => error.is_retryable(),
468            Self::Retry {
469                reason: RetryReason::MaxRetriesExceeded,
470                errors,
471                ..
472            } => errors.last().is_some_and(ProviderError::is_retryable),
473            Self::Stream(error) => error.is_retryable.unwrap_or(false),
474            _ => false,
475        }
476    }
477
478    /// Returns `true` when the error is the result of cancellation.
479    #[must_use]
480    pub fn is_cancelled(&self) -> bool {
481        matches!(
482            self,
483            Self::Cancelled
484                | Self::Retry {
485                    reason: RetryReason::Abort,
486                    ..
487                }
488        )
489    }
490
491    /// Returns the HTTP status of the underlying provider response, if any.
492    #[must_use]
493    pub fn status_code(&self) -> Option<StatusCode> {
494        match self {
495            Self::Provider(error) => error.status_code(),
496            Self::Retry { errors, .. } => errors.last().and_then(ProviderError::status_code),
497            Self::Download(details) => details.status_code,
498            Self::Stream(error) => error
499                .status_code
500                .and_then(|code| StatusCode::from_u16(code).ok()),
501            _ => None,
502        }
503    }
504
505    /// Returns the provider error when this error wraps one.
506    #[must_use]
507    pub fn as_provider(&self) -> Option<&ProviderError> {
508        match self {
509            Self::Provider(error) => Some(error),
510            _ => None,
511        }
512    }
513
514    /// Returns the coarse category.
515    #[must_use]
516    pub fn kind(&self) -> ErrorKind {
517        match self {
518            Self::Provider(_) | Self::Stream(_) => ErrorKind::Provider,
519            Self::Retry { .. } => ErrorKind::Retry,
520            Self::Timeout { .. } => ErrorKind::Timeout,
521            Self::Cancelled => ErrorKind::Cancelled,
522            Self::InvalidArgument { .. }
523            | Self::InvalidPrompt { .. }
524            | Self::MessageConversion { .. }
525            | Self::Download(_)
526            | Self::InvalidDataContent { .. }
527            | Self::InvalidStreamPart { .. } => ErrorKind::InvalidInput,
528            Self::NoSuchTool { .. }
529            | Self::InvalidToolInput(_)
530            | Self::ToolCallRepair { .. }
531            | Self::ToolChoiceViolation { .. }
532            | Self::ToolChoiceNotSatisfied { .. }
533            | Self::ToolCallNotFoundForApproval { .. }
534            | Self::InvalidToolApproval { .. } => ErrorKind::Tool,
535            Self::NoObjectGenerated(_)
536            | Self::NoOutputGenerated
537            | Self::NoImageGenerated { .. }
538            | Self::NoSpeechGenerated { .. }
539            | Self::NoTranscriptGenerated { .. }
540            | Self::NoTranslationGenerated { .. }
541            | Self::NoVideoGenerated { .. } => ErrorKind::Output,
542            Self::NoSuchProvider(_) | Self::NoDefaultRegistry { .. } => ErrorKind::NotFound,
543            Self::Mcp(_) => ErrorKind::Mcp,
544            Self::Other(_) => ErrorKind::Other,
545        }
546    }
547}
548
549impl From<ProviderError> for Error {
550    fn from(error: ProviderError) -> Self {
551        match error {
552            ProviderError::Cancelled => Self::Cancelled,
553            other => Self::Provider(Box::new(other)),
554        }
555    }
556}
557
558impl From<ferrin_spec::error::NoSuchModelError> for Error {
559    fn from(error: ferrin_spec::error::NoSuchModelError) -> Self {
560        Self::Provider(Box::new(ProviderError::NoSuchModel(Box::new(error))))
561    }
562}
563
564impl From<ferrin_spec::error::InvalidArgumentError> for Error {
565    fn from(error: ferrin_spec::error::InvalidArgumentError) -> Self {
566        Self::InvalidArgument {
567            argument: error.argument,
568            message: error.message,
569        }
570    }
571}
572
573impl From<ferrin_tool::ToolError> for Error {
574    fn from(error: ferrin_tool::ToolError) -> Self {
575        match error {
576            ferrin_tool::ToolError::Cancelled => Self::Cancelled,
577            other => Self::Other(Box::new(other)),
578        }
579    }
580}