Skip to main content

temporalio_client/
errors.rs

1//! Contains errors that can be returned by clients.
2
3#[cfg(feature = "experimental")]
4use crate::PluginApplyError;
5use crate::{WorkflowExecutionStatus, workflow_handle::WorkflowResultDetails};
6use http::uri::InvalidUri;
7use temporalio_common::{
8    data_converters::{DecodablePayloads, PayloadConversionError},
9    error::{IncomingError, TimeoutType},
10    protos::{
11        google::rpc::Status as RpcStatus,
12        temporal::api::{
13            errordetails::v1::{
14                ActivityExecutionAlreadyStartedFailure, MultiOperationExecutionFailure,
15                WorkflowExecutionAlreadyStartedFailure,
16                multi_operation_execution_failure::OperationStatus,
17            },
18            failure::v1::Failure,
19        },
20        utilities::{decode_status_detail, encode_status_details},
21    },
22};
23use tonic::Code;
24
25/// Errors thrown while attempting to establish a connection to the server
26#[derive(thiserror::Error, Debug)]
27#[non_exhaustive]
28pub enum ClientConnectError {
29    /// A plugin failed while configuring connection options.
30    #[cfg(feature = "experimental")]
31    #[error(transparent)]
32    Plugin(#[from] PluginApplyError),
33    /// Invalid URI. Configuration error, fatal.
34    #[error("Invalid URI: {0:?}")]
35    InvalidUri(#[from] InvalidUri),
36    /// Invalid gRPC metadata headers. Configuration error.
37    #[error("Invalid headers: {0}")]
38    InvalidHeaders(#[from] InvalidHeaderError),
39    /// Server connection error. Crashing and restarting the worker is likely best.
40    #[error("Server connection error: {0:?}")]
41    TonicTransportError(#[from] tonic::transport::Error),
42    /// We couldn't successfully make the `get_system_info` call at connection time to establish
43    /// server capabilities / verify server is responding.
44    #[error("`get_system_info` call error after connection: {0:?}")]
45    SystemInfoCallError(tonic::Status),
46    /// DNS resolution failed when attempting load-balanced connection.
47    #[error("DNS resolution error for '{host}': {source}")]
48    DnsResolutionError {
49        /// The host that failed to resolve.
50        host: String,
51        /// The underlying IO error.
52        #[source]
53        source: std::io::Error,
54    },
55    /// Invalid client configuration.
56    #[error("Invalid client configuration: {0}")]
57    InvalidConfig(String),
58}
59
60impl From<ClientNewError> for ClientConnectError {
61    fn from(value: ClientNewError) -> Self {
62        match value {
63            #[cfg(feature = "experimental")]
64            ClientNewError::Plugin(err) => Self::Plugin(err),
65        }
66    }
67}
68
69/// Errors thrown when a gRPC metadata header is invalid.
70#[derive(thiserror::Error, Debug)]
71#[non_exhaustive]
72pub enum InvalidHeaderError {
73    /// A binary header key was invalid
74    #[error("Invalid binary header key '{key}': {source}")]
75    InvalidBinaryHeaderKey {
76        /// The invalid key
77        key: String,
78        /// The source error from tonic
79        source: tonic::metadata::errors::InvalidMetadataKey,
80    },
81    /// An ASCII header key was invalid
82    #[error("Invalid ASCII header key '{key}': {source}")]
83    InvalidAsciiHeaderKey {
84        /// The invalid key
85        key: String,
86        /// The source error from tonic
87        source: tonic::metadata::errors::InvalidMetadataKey,
88    },
89    /// An ASCII header value was invalid
90    #[error("Invalid ASCII header value for key '{key}': {source}")]
91    InvalidAsciiHeaderValue {
92        /// The key
93        key: String,
94        /// The invalid value
95        value: String,
96        /// The source error from tonic
97        source: tonic::metadata::errors::InvalidMetadataValue,
98    },
99}
100
101/// Errors that can occur when starting a workflow.
102#[derive(thiserror::Error, Debug)]
103#[non_exhaustive]
104pub enum WorkflowStartError {
105    /// The workflow already exists.
106    #[error("Workflow already started with run ID: {run_id:?}")]
107    AlreadyStarted {
108        /// Run ID of the already-started workflow if this was raised by the client.
109        run_id: Option<String>,
110        /// The original gRPC status from the server.
111        #[source]
112        source: tonic::Status,
113    },
114    /// Error converting the input to a payload.
115    #[error("Failed to serialize workflow input: {0}")]
116    PayloadConversion(#[from] PayloadConversionError),
117    /// An uncategorized rpc error from the server.
118    #[error("Server error: {0}")]
119    Rpc(#[from] tonic::Status),
120}
121
122impl WorkflowStartError {
123    pub(crate) fn from_status(status: tonic::Status) -> Self {
124        if status.code() == Code::AlreadyExists {
125            let run_id =
126                decode_status_detail::<WorkflowExecutionAlreadyStartedFailure>(status.details())
127                    .map(|failure| failure.run_id);
128            Self::AlreadyStarted {
129                run_id,
130                source: status,
131            }
132        } else {
133            Self::Rpc(status)
134        }
135    }
136}
137
138/// Errors returned by query operations on [crate::WorkflowHandle].
139#[derive(Debug, thiserror::Error)]
140#[non_exhaustive]
141pub enum WorkflowQueryError {
142    /// The workflow was not found.
143    #[error("Workflow not found")]
144    NotFound(#[source] tonic::Status),
145
146    /// The query was rejected based on the rejection condition.
147    #[error("Query rejected: workflow status {status:?}")]
148    Rejected {
149        /// The workflow status that caused the query rejection, if reported.
150        status: Option<WorkflowExecutionStatus>,
151    },
152
153    /// Error serializing input or deserializing output.
154    #[error("Payload conversion error: {0}")]
155    PayloadConversion(#[from] PayloadConversionError),
156
157    /// An uncategorized RPC error from the server.
158    #[error("Server error: {0}")]
159    Rpc(tonic::Status),
160
161    /// Other errors.
162    #[error(transparent)]
163    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
164}
165
166impl WorkflowQueryError {
167    pub(crate) fn from_status(status: tonic::Status) -> Self {
168        if status.code() == Code::NotFound {
169            Self::NotFound(status)
170        } else {
171            Self::Rpc(status)
172        }
173    }
174}
175
176/// Errors returned by update operations on [crate::WorkflowHandle].
177#[derive(Debug, thiserror::Error)]
178#[non_exhaustive]
179pub enum WorkflowUpdateError {
180    /// The workflow was not found.
181    #[error("Workflow not found")]
182    NotFound(#[source] tonic::Status),
183
184    /// The update failed with an application-level failure.
185    #[error("Update failed: {0:?}")]
186    Failed(Box<Failure>),
187
188    /// Error serializing input or deserializing output.
189    #[error("Payload conversion error: {0}")]
190    PayloadConversion(#[from] PayloadConversionError),
191
192    /// An uncategorized RPC error from the server.
193    #[error("Server error: {0}")]
194    Rpc(tonic::Status),
195
196    /// Other errors.
197    #[error(transparent)]
198    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
199}
200
201impl WorkflowUpdateError {
202    pub(crate) fn from_status(status: tonic::Status) -> Self {
203        if status.code() == Code::NotFound {
204            Self::NotFound(status)
205        } else {
206            Self::Rpc(status)
207        }
208    }
209}
210
211/// Errors returned by update-with-start operations
212/// (see [crate::Client::start_update_with_start_workflow]).
213#[derive(Debug, thiserror::Error)]
214#[non_exhaustive]
215pub enum WorkflowUpdateWithStartError {
216    /// The start operation failed.
217    #[error("Workflow start failed: {0}")]
218    Start(#[source] WorkflowStartError),
219
220    /// The update operation failed, or waiting for the update result failed.
221    #[error("Workflow update failed: {0}")]
222    Update(#[source] WorkflowUpdateError),
223
224    /// Error serializing the workflow input or update arguments.
225    #[error("Payload conversion error: {0}")]
226    PayloadConversion(#[from] PayloadConversionError),
227
228    /// An RPC error from the server that could not be attributed to either operation.
229    #[error("Server error: {0}")]
230    Rpc(tonic::Status),
231
232    /// Other errors.
233    #[error(transparent)]
234    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
235}
236
237const MULTI_OPERATION_ABORTED_NAME: &str = "temporal.api.failure.v1.MultiOperationExecutionAborted";
238
239/// Reconstruct a standalone gRPC status from a multi-operation `OperationStatus`, re-encoding
240/// its details so the operation-specific failure information stays available to callers.
241fn operation_status_to_tonic(op_status: OperationStatus) -> tonic::Status {
242    let code = Code::from(op_status.code);
243    let details = encode_status_details(&RpcStatus {
244        code: op_status.code,
245        message: op_status.message.clone(),
246        details: op_status.details,
247    });
248    tonic::Status::with_details(code, op_status.message, details.into())
249}
250
251impl WorkflowUpdateWithStartError {
252    /// A multi-operation failure carries one status per operation; all operations except the
253    /// failed one are marked aborted. Attribute the error to the operation that actually failed
254    /// (index 0 is the start operation, index 1 the update).
255    pub(crate) fn from_status(status: tonic::Status) -> Self {
256        let Some(failure) =
257            decode_status_detail::<MultiOperationExecutionFailure>(status.details())
258        else {
259            return Self::Rpc(status);
260        };
261        let culprit = failure
262            .statuses
263            .into_iter()
264            .enumerate()
265            .find(|(_, op_status)| {
266                op_status.code != Code::Ok as i32
267                    && !op_status
268                        .details
269                        .iter()
270                        .any(|detail| detail.type_url.ends_with(MULTI_OPERATION_ABORTED_NAME))
271            });
272        match culprit {
273            Some((0, op_status)) => Self::Start(WorkflowStartError::from_status(
274                operation_status_to_tonic(op_status),
275            )),
276            Some((_, op_status)) => Self::Update(WorkflowUpdateError::from_status(
277                operation_status_to_tonic(op_status),
278            )),
279            None => Self::Rpc(status),
280        }
281    }
282}
283
284/// Errors returned by workflow get_result operations.
285#[derive(Debug, thiserror::Error)]
286#[non_exhaustive]
287pub enum WorkflowGetResultError {
288    /// The workflow finished in failure.
289    #[error("Workflow failed: {0}")]
290    Failed(#[source] Box<IncomingError>),
291
292    /// The workflow was cancelled.
293    #[error("Workflow cancelled")]
294    Cancelled {
295        /// Details provided at cancellation time.
296        details: WorkflowResultDetails,
297    },
298
299    /// The workflow was terminated.
300    #[error("Workflow terminated")]
301    Terminated {
302        /// Details provided at termination time.
303        details: WorkflowResultDetails,
304    },
305
306    /// The workflow timed out.
307    #[error("Workflow timed out")]
308    TimedOut,
309
310    /// The workflow continued as new.
311    #[error("Workflow continued as new")]
312    ContinuedAsNew,
313
314    /// The workflow was not found.
315    #[error("Workflow not found")]
316    NotFound(#[source] tonic::Status),
317
318    /// Error serializing input or deserializing output.
319    #[error("Payload conversion error: {0}")]
320    PayloadConversion(#[from] PayloadConversionError),
321
322    /// An uncategorized RPC error from the server.
323    #[error("Server error: {0}")]
324    Rpc(tonic::Status),
325
326    /// Other errors.
327    #[error(transparent)]
328    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
329}
330
331impl From<WorkflowInteractionError> for WorkflowGetResultError {
332    fn from(err: WorkflowInteractionError) -> Self {
333        match err {
334            WorkflowInteractionError::NotFound(s) => Self::NotFound(s),
335            WorkflowInteractionError::PayloadConversion(e) => Self::PayloadConversion(e),
336            WorkflowInteractionError::Rpc(s) => Self::Rpc(s),
337            WorkflowInteractionError::Other(e) => Self::Other(e),
338        }
339    }
340}
341
342impl WorkflowGetResultError {
343    /// Returns `true` if this error represents a workflow-level non-success outcome
344    /// (Failed, Cancelled, Terminated, TimedOut, or ContinuedAsNew) rather than an
345    /// infrastructure/RPC error.
346    pub fn is_workflow_outcome(&self) -> bool {
347        matches!(
348            self,
349            Self::Failed(_)
350                | Self::Cancelled { .. }
351                | Self::Terminated { .. }
352                | Self::TimedOut
353                | Self::ContinuedAsNew
354        )
355    }
356}
357
358/// Errors returned by client methods that don't need more specific error types.
359#[derive(thiserror::Error, Debug)]
360#[non_exhaustive]
361pub enum ClientError {
362    /// Error decoding payloads returned by the server.
363    #[error("Payload conversion error: {0}")]
364    PayloadConversion(#[from] PayloadConversionError),
365    /// An uncategorized rpc error from the server.
366    #[error("Server error: {0}")]
367    Rpc(#[from] tonic::Status),
368}
369
370/// Errors returned by methods on [crate::WorkflowHandle] for general operations
371/// like signal, cancel, terminate, describe, fetch_history, and get_result.
372#[derive(Debug, thiserror::Error)]
373#[non_exhaustive]
374pub enum WorkflowInteractionError {
375    /// The workflow was not found.
376    #[error("Workflow not found")]
377    NotFound(#[source] tonic::Status),
378
379    /// Error serializing input or deserializing output.
380    #[error("Payload conversion error: {0}")]
381    PayloadConversion(#[from] PayloadConversionError),
382
383    /// An uncategorized RPC error from the server.
384    #[error("Server error: {0}")]
385    Rpc(tonic::Status),
386
387    /// Other errors.
388    #[error(transparent)]
389    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
390}
391
392impl WorkflowInteractionError {
393    pub(crate) fn from_status(status: tonic::Status) -> Self {
394        if status.code() == Code::NotFound {
395            Self::NotFound(status)
396        } else {
397            Self::Rpc(status)
398        }
399    }
400}
401
402/// Errors that can occur when completing an activity asynchronously.
403#[derive(Debug, thiserror::Error)]
404#[non_exhaustive]
405pub enum AsyncActivityError {
406    /// The activity was not found (e.g., already completed, cancelled, or never existed).
407    #[error("Activity not found")]
408    NotFound(#[source] tonic::Status),
409    /// Error serializing an activity result, failure, or details.
410    #[error("Payload conversion error: {0}")]
411    PayloadConversion(#[from] PayloadConversionError),
412    /// An uncategorized rpc error from the server.
413    #[error("Server error: {0}")]
414    Rpc(#[from] tonic::Status),
415}
416
417impl AsyncActivityError {
418    pub(crate) fn from_status(status: tonic::Status) -> Self {
419        if status.code() == Code::NotFound {
420            Self::NotFound(status)
421        } else {
422            Self::Rpc(status)
423        }
424    }
425}
426
427/// Errors that can occur when constructing a [`crate::Client`].
428#[derive(Debug, thiserror::Error)]
429#[non_exhaustive]
430pub enum ClientNewError {
431    /// A plugin failed while configuring client options.
432    #[cfg(feature = "experimental")]
433    #[error(transparent)]
434    Plugin(#[from] PluginApplyError),
435}
436
437/// Errors returned by methods on [crate::ActivityHandle] that don't need more specific error types.
438#[derive(Debug, thiserror::Error)]
439#[non_exhaustive]
440pub enum ActivityInteractionError {
441    /// The activity was not found.
442    #[error("Activity not found")]
443    NotFound(#[source] tonic::Status),
444
445    /// Error deserializing output.
446    #[error("Payload conversion error: {0}")]
447    PayloadConversion(#[from] PayloadConversionError),
448
449    /// An uncategorized RPC error from the server.
450    #[error("Server error: {0}")]
451    Rpc(#[source] tonic::Status),
452
453    /// Other errors.
454    #[error(transparent)]
455    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
456}
457
458impl From<tonic::Status> for ActivityInteractionError {
459    fn from(status: tonic::Status) -> Self {
460        if status.code() == Code::NotFound {
461            Self::NotFound(status)
462        } else {
463            Self::Rpc(status)
464        }
465    }
466}
467
468/// Errors that can occur when starting a standalone activity.
469#[allow(clippy::large_enum_variant)]
470#[derive(Debug, thiserror::Error)]
471#[non_exhaustive]
472pub enum StartActivityError {
473    /// There's a conflicting activity execution with the same ID according to chosen ID reuse
474    /// policy and ID conflict policy.
475    #[error("Activity already started with run_id={run_id}")]
476    AlreadyStarted {
477        /// Run ID of the existing execution with the same activity ID.
478        run_id: String,
479        /// Raw error from the server.
480        #[source]
481        source: tonic::Status,
482    },
483
484    /// Error serializing input.
485    #[error("Payload conversion error: {0}")]
486    PayloadConversion(#[from] PayloadConversionError),
487
488    /// An uncategorized RPC error from the server.
489    #[error("Server error: {0}")]
490    Rpc(#[source] tonic::Status),
491
492    /// Other errors.
493    #[error(transparent)]
494    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
495}
496
497impl From<tonic::Status> for StartActivityError {
498    fn from(status: tonic::Status) -> Self {
499        if status.code() == tonic::Code::AlreadyExists
500            && let Some(details) =
501                decode_status_detail::<ActivityExecutionAlreadyStartedFailure>(status.details())
502        {
503            StartActivityError::AlreadyStarted {
504                run_id: details.run_id,
505                source: status,
506            }
507        } else {
508            StartActivityError::Rpc(status)
509        }
510    }
511}
512
513/// Errors returned by [`crate::ActivityHandle::result`].
514#[allow(clippy::large_enum_variant)]
515#[derive(Debug, thiserror::Error)]
516#[non_exhaustive]
517pub enum ActivityResultError {
518    /// Activity execution did not complete successfully.
519    #[error("Activity failed: {0}")]
520    ActivityFailed(#[source] IncomingError),
521
522    /// The activity was canceled.
523    #[error("Activity canceled")]
524    Cancelled {
525        /// Details provided at cancellation time.
526        details: DecodablePayloads,
527    },
528
529    /// The workflow was terminated.
530    #[error("Activity terminated")]
531    Terminated,
532
533    /// The activity timed out.
534    #[error("Activity timed out: {0:?}")]
535    TimedOut(TimeoutType),
536
537    /// The activity was not found.
538    #[error("Activity not found")]
539    NotFound(#[source] tonic::Status),
540
541    /// Error deserializing output.
542    #[error("Payload conversion error: {0}")]
543    PayloadConversion(#[from] PayloadConversionError),
544
545    /// An uncategorized RPC error from the server.
546    #[error("Server error: {0}")]
547    Rpc(#[source] tonic::Status),
548
549    /// Other errors.
550    #[error(transparent)]
551    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
552}
553
554impl From<tonic::Status> for ActivityResultError {
555    fn from(status: tonic::Status) -> Self {
556        if status.code() == Code::NotFound {
557            Self::NotFound(status)
558        } else {
559            Self::Rpc(status)
560        }
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use assert_matches::assert_matches;
568    use prost::Message;
569    use temporalio_common::protos::{
570        temporal::api::{
571            errordetails::v1::NotFoundFailure, failure::v1::MultiOperationExecutionAborted,
572        },
573        utilities::pack_any,
574    };
575
576    fn multi_op_status(code: Code, statuses: Vec<OperationStatus>) -> tonic::Status {
577        let failure = MultiOperationExecutionFailure { statuses };
578        let rpc_status = RpcStatus {
579            code: code as i32,
580            message: "multi-op failure".to_owned(),
581            details: vec![
582                pack_any(
583                    "type.googleapis.com/temporal.api.errordetails.v1.MultiOperationExecutionFailure"
584                        .to_owned(),
585                    &failure,
586                )
587                .unwrap(),
588            ],
589        };
590        tonic::Status::with_details(code, "multi-op failure", rpc_status.encode_to_vec().into())
591    }
592
593    fn aborted_status() -> OperationStatus {
594        OperationStatus {
595            code: Code::Aborted as i32,
596            message: "aborted".to_owned(),
597            details: vec![
598                pack_any(
599                    "type.googleapis.com/temporal.api.failure.v1.MultiOperationExecutionAborted"
600                        .to_owned(),
601                    &MultiOperationExecutionAborted {},
602                )
603                .unwrap(),
604            ],
605        }
606    }
607
608    #[test]
609    fn update_with_start_error_attributes_start_already_started() {
610        let status = multi_op_status(
611            Code::AlreadyExists,
612            vec![
613                OperationStatus {
614                    code: Code::AlreadyExists as i32,
615                    message: "already started".to_owned(),
616                    details: vec![
617                        pack_any(
618                            "type.googleapis.com/temporal.api.errordetails.v1.WorkflowExecutionAlreadyStartedFailure"
619                                .to_owned(),
620                            &WorkflowExecutionAlreadyStartedFailure {
621                                run_id: "existing-run".to_owned(),
622                                ..Default::default()
623                            },
624                        )
625                        .unwrap(),
626                    ],
627                },
628                aborted_status(),
629            ],
630        );
631
632        let err = WorkflowUpdateWithStartError::from_status(status);
633        assert_matches!(
634            err,
635            WorkflowUpdateWithStartError::Start(WorkflowStartError::AlreadyStarted {
636                run_id: Some(run_id),
637                ..
638            }) if run_id == "existing-run"
639        );
640    }
641
642    #[test]
643    fn update_with_start_error_attributes_update_failure() {
644        let status = multi_op_status(
645            Code::NotFound,
646            vec![
647                aborted_status(),
648                OperationStatus {
649                    code: Code::NotFound as i32,
650                    message: "no such workflow".to_owned(),
651                    details: vec![
652                        pack_any(
653                            "type.googleapis.com/temporal.api.errordetails.v1.NotFoundFailure"
654                                .to_owned(),
655                            &NotFoundFailure {
656                                current_cluster: "here".to_owned(),
657                                ..Default::default()
658                            },
659                        )
660                        .unwrap(),
661                    ],
662                },
663            ],
664        );
665
666        let err = WorkflowUpdateWithStartError::from_status(status);
667        let inner = assert_matches!(
668            err,
669            WorkflowUpdateWithStartError::Update(WorkflowUpdateError::NotFound(status)) => status
670        );
671        assert_eq!(inner.message(), "no such workflow");
672        // The operation's own failure details must survive reconstruction of the inner status.
673        let detail = decode_status_detail::<NotFoundFailure>(inner.details())
674            .expect("operation details must be preserved");
675        assert_eq!(detail.current_cluster, "here");
676    }
677
678    #[test]
679    fn update_with_start_error_skips_successful_start() {
680        let status = multi_op_status(
681            Code::NotFound,
682            vec![
683                OperationStatus {
684                    code: Code::Ok as i32,
685                    message: String::new(),
686                    details: vec![],
687                },
688                OperationStatus {
689                    code: Code::NotFound as i32,
690                    message: "update failed".to_owned(),
691                    details: vec![],
692                },
693            ],
694        );
695
696        let err = WorkflowUpdateWithStartError::from_status(status);
697        assert_matches!(
698            err,
699            WorkflowUpdateWithStartError::Update(WorkflowUpdateError::NotFound(status))
700                if status.message() == "update failed"
701        );
702    }
703
704    #[test]
705    fn update_with_start_error_without_details_is_rpc() {
706        let err =
707            WorkflowUpdateWithStartError::from_status(tonic::Status::new(Code::Internal, "boom"));
708        assert_matches!(err, WorkflowUpdateWithStartError::Rpc(status) if status.code() == Code::Internal);
709    }
710}