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    /// Every video call returned no video.
189    #[error("no video generated")]
190    NoVideoGenerated {
191        /// Responses of the attempted calls.
192        responses: Vec<ResponseMetadata>,
193    },
194
195    /// The registry has no provider with the requested id.
196    #[error("no such provider `{}`", .0.provider_id)]
197    NoSuchProvider(Box<NoSuchProviderDetails>),
198
199    /// A `provider:model` string was used without a default registry.
200    #[error("no default registry configured for model id `{model_id}`")]
201    NoDefaultRegistry {
202        /// The unresolved id.
203        model_id: String,
204    },
205
206    /// The provider stream violated the specification contract.
207    #[error("invalid stream part: {message}")]
208    InvalidStreamPart {
209        /// What is wrong.
210        message: String,
211    },
212
213    /// The provider stream reported an error part.
214    #[error("stream error: {}", .0.message)]
215    Stream(Box<StreamError>),
216
217    /// An MCP error (type-erased: the core does not depend on the MCP crate).
218    #[error(transparent)]
219    Mcp(BoxError),
220
221    /// Any other error.
222    #[error(transparent)]
223    Other(BoxError),
224}
225
226/// Why a retry loop stopped.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "kebab-case")]
229pub enum RetryReason {
230    /// The last attempt failed and no retries were left.
231    MaxRetriesExceeded,
232    /// A non-retryable error occurred after at least one retry.
233    ErrorNotRetryable,
234    /// The cancellation token fired while waiting to retry.
235    Abort,
236}
237
238impl std::fmt::Display for RetryReason {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.write_str(match self {
241            Self::MaxRetriesExceeded => "max retries exceeded",
242            Self::ErrorNotRetryable => "error not retryable",
243            Self::Abort => "aborted",
244        })
245    }
246}
247
248/// Payload of [`Error::Download`].
249#[derive(Debug, thiserror::Error)]
250#[error("download of {url} failed")]
251pub struct DownloadDetails {
252    /// The URL.
253    pub url: Url,
254    /// HTTP status when the server answered.
255    pub status_code: Option<StatusCode>,
256    /// Underlying cause.
257    #[source]
258    pub cause: Option<BoxError>,
259}
260
261/// Payload of [`Error::InvalidToolInput`].
262#[derive(Debug, thiserror::Error)]
263#[error("invalid input for tool `{tool_name}`: {tool_input}")]
264pub struct InvalidToolInputDetails {
265    /// The tool.
266    pub tool_name: ToolName,
267    /// The raw input text.
268    pub tool_input: String,
269    /// The parse or validation error.
270    #[source]
271    pub cause: BoxError,
272}
273
274/// Payload of [`Error::NoObjectGenerated`].
275#[derive(Debug, thiserror::Error)]
276#[error("{message}")]
277pub struct NoObjectGeneratedDetails {
278    /// Why parsing failed.
279    pub message: String,
280    /// The text the model produced, if any.
281    pub text: Option<String>,
282    /// Response metadata of the last step.
283    pub response: ResponseMetadata,
284    /// Usage of the last step.
285    pub usage: Usage,
286    /// Finish reason of the last step.
287    pub finish_reason: FinishReason,
288    /// Underlying cause.
289    #[source]
290    pub cause: Option<BoxError>,
291}
292
293/// Payload of [`Error::NoSuchProvider`].
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct NoSuchProviderDetails {
296    /// The requested provider id.
297    pub provider_id: ProviderId,
298    /// Providers the registry knows.
299    pub available_providers: Vec<ProviderId>,
300    /// The full model id that was requested.
301    pub model_id: String,
302    /// The kind of model requested.
303    pub model_kind: ModelKind,
304}
305
306/// Low-cardinality error category for logs and telemetry.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
308#[serde(rename_all = "kebab-case")]
309#[non_exhaustive]
310pub enum ErrorKind {
311    /// Provider adapter failure.
312    Provider,
313    /// Retries exhausted.
314    Retry,
315    /// Timeout.
316    Timeout,
317    /// Cancelled.
318    Cancelled,
319    /// Invalid input (arguments, prompt, data content, stream contract).
320    InvalidInput,
321    /// Tool-related failure (unknown tool, invalid input, approval).
322    Tool,
323    /// Output-related failure (no object/output/image/...).
324    Output,
325    /// Missing provider or registry.
326    NotFound,
327    /// MCP failure.
328    Mcp,
329    /// Anything else.
330    Other,
331}
332
333impl ErrorKind {
334    /// Returns the kebab-case name.
335    #[must_use]
336    pub fn as_str(self) -> &'static str {
337        match self {
338            Self::Provider => "provider",
339            Self::Retry => "retry",
340            Self::Timeout => "timeout",
341            Self::Cancelled => "cancelled",
342            Self::InvalidInput => "invalid-input",
343            Self::Tool => "tool",
344            Self::Output => "output",
345            Self::NotFound => "not-found",
346            Self::Mcp => "mcp",
347            Self::Other => "other",
348        }
349    }
350}
351
352impl std::fmt::Display for ErrorKind {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        f.write_str(self.as_str())
355    }
356}
357
358impl Error {
359    /// Creates an [`Error::InvalidArgument`].
360    #[must_use]
361    pub fn invalid_argument(argument: impl Into<String>, message: impl Into<String>) -> Self {
362        Self::InvalidArgument {
363            argument: argument.into(),
364            message: message.into(),
365        }
366    }
367
368    /// Creates an [`Error::InvalidPrompt`].
369    #[must_use]
370    pub fn invalid_prompt(message: impl Into<String>) -> Self {
371        Self::InvalidPrompt {
372            message: message.into(),
373        }
374    }
375
376    /// Creates an [`Error::InvalidDataContent`].
377    #[must_use]
378    pub fn invalid_data_content(message: impl Into<String>, cause: Option<BoxError>) -> Self {
379        Self::InvalidDataContent {
380            message: message.into(),
381            cause,
382        }
383    }
384
385    /// Creates an [`Error::Download`].
386    #[must_use]
387    pub fn download(url: Url, status_code: Option<StatusCode>, cause: Option<BoxError>) -> Self {
388        Self::Download(Box::new(DownloadDetails {
389            url,
390            status_code,
391            cause,
392        }))
393    }
394
395    /// Creates an [`Error::NoSuchTool`].
396    #[must_use]
397    pub fn no_such_tool(tool_name: impl Into<ToolName>, available_tools: Vec<ToolName>) -> Self {
398        Self::NoSuchTool {
399            tool_name: tool_name.into(),
400            available_tools,
401        }
402    }
403
404    /// Creates an [`Error::InvalidToolInput`].
405    #[must_use]
406    pub fn invalid_tool_input(
407        tool_name: impl Into<ToolName>,
408        tool_input: impl Into<String>,
409        cause: impl Into<BoxError>,
410    ) -> Self {
411        Self::InvalidToolInput(Box::new(InvalidToolInputDetails {
412            tool_name: tool_name.into(),
413            tool_input: tool_input.into(),
414            cause: cause.into(),
415        }))
416    }
417
418    /// Creates an [`Error::NoObjectGenerated`].
419    #[must_use]
420    pub fn no_object_generated(details: NoObjectGeneratedDetails) -> Self {
421        Self::NoObjectGenerated(Box::new(details))
422    }
423
424    /// Creates an [`Error::NoSuchProvider`].
425    #[must_use]
426    pub fn no_such_provider(details: NoSuchProviderDetails) -> Self {
427        Self::NoSuchProvider(Box::new(details))
428    }
429
430    /// Creates an [`Error::InvalidStreamPart`].
431    #[must_use]
432    pub fn invalid_stream_part(message: impl Into<String>) -> Self {
433        Self::InvalidStreamPart {
434            message: message.into(),
435        }
436    }
437
438    /// Wraps a stream error part as [`Error::Stream`].
439    #[must_use]
440    pub fn stream(error: StreamError) -> Self {
441        Self::Stream(Box::new(error))
442    }
443
444    /// Wraps any error as [`Error::Other`].
445    #[must_use]
446    pub fn other(error: impl std::error::Error + Send + Sync + 'static) -> Self {
447        Self::Other(Box::new(error))
448    }
449
450    /// Wraps a message as [`Error::Other`].
451    #[must_use]
452    pub fn message(message: impl Into<String>) -> Self {
453        Self::Other(message.into().into())
454    }
455
456    /// Returns `true` when retrying the operation may succeed.
457    #[must_use]
458    pub fn is_retryable(&self) -> bool {
459        match self {
460            Self::Provider(error) => error.is_retryable(),
461            Self::Retry {
462                reason: RetryReason::MaxRetriesExceeded,
463                errors,
464                ..
465            } => errors.last().is_some_and(ProviderError::is_retryable),
466            Self::Stream(error) => error.is_retryable.unwrap_or(false),
467            _ => false,
468        }
469    }
470
471    /// Returns `true` when the error is the result of cancellation.
472    #[must_use]
473    pub fn is_cancelled(&self) -> bool {
474        matches!(
475            self,
476            Self::Cancelled
477                | Self::Retry {
478                    reason: RetryReason::Abort,
479                    ..
480                }
481        )
482    }
483
484    /// Returns the HTTP status of the underlying provider response, if any.
485    #[must_use]
486    pub fn status_code(&self) -> Option<StatusCode> {
487        match self {
488            Self::Provider(error) => error.status_code(),
489            Self::Retry { errors, .. } => errors.last().and_then(ProviderError::status_code),
490            Self::Download(details) => details.status_code,
491            Self::Stream(error) => error
492                .status_code
493                .and_then(|code| StatusCode::from_u16(code).ok()),
494            _ => None,
495        }
496    }
497
498    /// Returns the provider error when this error wraps one.
499    #[must_use]
500    pub fn as_provider(&self) -> Option<&ProviderError> {
501        match self {
502            Self::Provider(error) => Some(error),
503            _ => None,
504        }
505    }
506
507    /// Returns the coarse category.
508    #[must_use]
509    pub fn kind(&self) -> ErrorKind {
510        match self {
511            Self::Provider(_) | Self::Stream(_) => ErrorKind::Provider,
512            Self::Retry { .. } => ErrorKind::Retry,
513            Self::Timeout { .. } => ErrorKind::Timeout,
514            Self::Cancelled => ErrorKind::Cancelled,
515            Self::InvalidArgument { .. }
516            | Self::InvalidPrompt { .. }
517            | Self::MessageConversion { .. }
518            | Self::Download(_)
519            | Self::InvalidDataContent { .. }
520            | Self::InvalidStreamPart { .. } => ErrorKind::InvalidInput,
521            Self::NoSuchTool { .. }
522            | Self::InvalidToolInput(_)
523            | Self::ToolCallRepair { .. }
524            | Self::ToolChoiceViolation { .. }
525            | Self::ToolChoiceNotSatisfied { .. }
526            | Self::ToolCallNotFoundForApproval { .. }
527            | Self::InvalidToolApproval { .. } => ErrorKind::Tool,
528            Self::NoObjectGenerated(_)
529            | Self::NoOutputGenerated
530            | Self::NoImageGenerated { .. }
531            | Self::NoSpeechGenerated { .. }
532            | Self::NoTranscriptGenerated { .. }
533            | Self::NoVideoGenerated { .. } => ErrorKind::Output,
534            Self::NoSuchProvider(_) | Self::NoDefaultRegistry { .. } => ErrorKind::NotFound,
535            Self::Mcp(_) => ErrorKind::Mcp,
536            Self::Other(_) => ErrorKind::Other,
537        }
538    }
539}
540
541impl From<ProviderError> for Error {
542    fn from(error: ProviderError) -> Self {
543        match error {
544            ProviderError::Cancelled => Self::Cancelled,
545            other => Self::Provider(Box::new(other)),
546        }
547    }
548}
549
550impl From<ferrin_spec::error::NoSuchModelError> for Error {
551    fn from(error: ferrin_spec::error::NoSuchModelError) -> Self {
552        Self::Provider(Box::new(ProviderError::NoSuchModel(Box::new(error))))
553    }
554}
555
556impl From<ferrin_spec::error::InvalidArgumentError> for Error {
557    fn from(error: ferrin_spec::error::InvalidArgumentError) -> Self {
558        Self::InvalidArgument {
559            argument: error.argument,
560            message: error.message,
561        }
562    }
563}
564
565impl From<ferrin_tool::ToolError> for Error {
566    fn from(error: ferrin_tool::ToolError) -> Self {
567        match error {
568            ferrin_tool::ToolError::Cancelled => Self::Cancelled,
569            other => Self::Other(Box::new(other)),
570        }
571    }
572}