Skip to main content

temporalio_sdk/
activities.rs

1//! Functionality related to defining and interacting with activities
2//!
3//!
4//! An example of defining an activity:
5//! ```
6//! use std::sync::{
7//!     Arc,
8//!     atomic::{AtomicUsize, Ordering},
9//! };
10//! use temporalio_macros::{activities, activity_definitions};
11//! use temporalio_sdk::activities::{ActivityContext, ActivityError};
12//!
13//! struct MyActivities {
14//!     counter: AtomicUsize,
15//! }
16//!
17//! #[activities]
18//! impl MyActivities {
19//!     #[activity]
20//!     async fn echo(_ctx: ActivityContext, e: String) -> Result<String, ActivityError> {
21//!         Ok(e)
22//!     }
23//!
24//!     #[activity]
25//!     async fn uses_self(self: Arc<Self>, _ctx: ActivityContext) -> Result<(), ActivityError> {
26//!         self.counter.fetch_add(1, Ordering::Relaxed);
27//!         Ok(())
28//!     }
29//! }
30//!
31//! // If you need to refer to an activity that is defined externally, in a different codebase or
32//! // possibly a different language, use `#[activity_definitions]`. Methods must omit the
33//! // `ActivityContext` parameter and have a body of `unimplemented!()`. Workflows can then call
34//! // these definitions just like real activities.
35//!
36//! struct ExternalActivities;
37//! #[activity_definitions]
38//! impl ExternalActivities {
39//!     #[activity(name = "foo")]
40//!     fn foo(_: String) -> Result<String, ActivityError> {
41//!         unimplemented!()
42//!     }
43//! }
44//! ```
45//!
46//! This will allows you to call the activity from workflow code still, but the actual function
47//! will never be invoked, since you won't have registered it with the worker.
48
49#[doc(inline)]
50pub use temporalio_macros::activities;
51
52use crate::{
53    OutgoingActivityError, OutgoingError,
54    interceptors::{
55        ActivityExecutionValue, ActivityInboundInterceptor, ExecuteActivityInput,
56        ExecuteActivityOutput, Next,
57    },
58    panic_formatter,
59};
60use futures_util::{
61    FutureExt,
62    future::{BoxFuture, ready},
63};
64use prost_types::{Duration, Timestamp};
65#[cfg(feature = "testing")]
66use std::any::Any;
67use std::{
68    collections::HashMap,
69    fmt::Debug,
70    panic::AssertUnwindSafe,
71    sync::Arc,
72    time::{Duration as StdDuration, SystemTime},
73};
74use temporalio_client::{Client, ClientOptions, Priority, WorkflowExecutionInfo, WorkflowHandle};
75pub use temporalio_common::ActivityError;
76use temporalio_common::{
77    ActivityDefinition, HasWorkflowDefinition, RetryPolicy,
78    data_converters::{
79        DataConverter, DecodablePayloads, GenericPayloadConverter, PayloadConversionError,
80        PayloadConverter, RawValue, SerializationContext, SerializationContextData,
81        TemporalDeserializable, TemporalSerializable,
82    },
83    error::ApplicationFailure,
84    protos::{
85        coresdk::{ActivityHeartbeat, activity_result::ActivityExecutionResult, activity_task},
86        temporal::api::common::v1::Payload,
87        utilities::TryIntoOrNone,
88    },
89};
90use temporalio_sdk_core::Worker as CoreWorker;
91use tokio_util::sync::CancellationToken;
92
93#[cfg(feature = "testing")]
94pub(crate) type ActivityHeartbeatCallback = Arc<dyn Fn(Box<dyn Any>) + Send + Sync>;
95
96/// Used within activities to get info, heartbeat management etc.
97#[derive(Clone)]
98pub struct ActivityContext {
99    backend: ActivityContextBackend,
100    cancellation_token: CancellationToken,
101    heartbeat_details: ActivityHeartbeatDetails,
102    header_fields: HashMap<String, Payload>,
103    info: ActivityInfo,
104}
105
106#[derive(Clone)]
107enum ActivityContextBackend {
108    Worker {
109        worker: Arc<CoreWorker>,
110        client_options: ClientOptions,
111    },
112    #[cfg(feature = "testing")]
113    Test {
114        client: Option<Client>,
115        heartbeat_callback: Option<ActivityHeartbeatCallback>,
116    },
117}
118
119impl ActivityContextBackend {
120    async fn record_heartbeat<T>(
121        &self,
122        task_token: &[u8],
123        details: T,
124    ) -> Result<(), PayloadConversionError>
125    where
126        T: TemporalSerializable + 'static,
127    {
128        match self {
129            Self::Worker {
130                worker,
131                client_options,
132            } => {
133                let details = client_options
134                    .data_converter
135                    .to_payloads(&SerializationContextData::Activity, &details)
136                    .await?;
137                worker.record_activity_heartbeat(ActivityHeartbeat {
138                    task_token: task_token.to_vec(),
139                    details,
140                });
141            }
142            #[cfg(feature = "testing")]
143            Self::Test {
144                heartbeat_callback, ..
145            } => {
146                if let Some(callback) = heartbeat_callback {
147                    callback(Box::new(details));
148                }
149            }
150        }
151        Ok(())
152    }
153
154    fn client(&self) -> Client {
155        match self {
156            Self::Worker {
157                worker,
158                client_options,
159            } => {
160                let connection = worker.get_client_connection().expect(
161                    "activity context client is unavailable because the worker was not created from a Temporal client",
162                );
163                Client::new(connection, client_options.clone())
164                    .expect("client construction from a worker connection should be infallible")
165            }
166            #[cfg(feature = "testing")]
167            Self::Test { client, .. } => client
168                .as_ref()
169                .expect("ActivityEnvironment was created without a Client. Pass one during construction to have one availalbe at runtime")
170                .clone(),
171        }
172    }
173}
174
175impl ActivityContext {
176    #[cfg(feature = "testing")]
177    pub(crate) fn new_for_test(
178        info: ActivityInfo,
179        header_fields: HashMap<String, Payload>,
180        payload_converter: PayloadConverter,
181        cancellation_token: CancellationToken,
182        heartbeat_details: Vec<Payload>,
183        client: Option<Client>,
184        heartbeat_callback: Option<ActivityHeartbeatCallback>,
185    ) -> Self {
186        let heartbeat_details = ActivityHeartbeatDetails::new(heartbeat_details, payload_converter);
187        Self {
188            backend: ActivityContextBackend::Test {
189                client,
190                heartbeat_callback,
191            },
192            cancellation_token,
193            heartbeat_details,
194            header_fields,
195            info,
196        }
197    }
198
199    pub(crate) fn new(
200        worker: Arc<CoreWorker>,
201        client_options: ClientOptions,
202        cancellation_token: CancellationToken,
203        task_queue: String,
204        task_token: Vec<u8>,
205        task: activity_task::Start,
206    ) -> (Self, Vec<Payload>) {
207        let activity_task::Start {
208            workflow_namespace,
209            workflow_type,
210            workflow_execution,
211            activity_id,
212            activity_type,
213            header_fields,
214            input,
215            heartbeat_details,
216            scheduled_time,
217            current_attempt_scheduled_time,
218            started_time,
219            attempt,
220            schedule_to_close_timeout,
221            start_to_close_timeout,
222            heartbeat_timeout,
223            retry_policy,
224            is_local,
225            priority,
226            run_id,
227        } = task;
228        let deadline = calculate_deadline(
229            scheduled_time.as_ref(),
230            started_time.as_ref(),
231            start_to_close_timeout.as_ref(),
232            schedule_to_close_timeout.as_ref(),
233        );
234        let heartbeat_details = ActivityHeartbeatDetails::new(
235            heartbeat_details,
236            client_options.data_converter.payload_converter().clone(),
237        );
238        let (workflow_id, workflow_run_id) = workflow_execution
239            .map(|we| (we.workflow_id, we.run_id))
240            .unzip();
241        let activity_run_id = (workflow_id.is_none() && !run_id.is_empty()).then_some(run_id);
242
243        (
244            ActivityContext {
245                backend: ActivityContextBackend::Worker {
246                    worker,
247                    client_options,
248                },
249                cancellation_token,
250                heartbeat_details,
251                header_fields,
252                info: ActivityInfo {
253                    task_token,
254                    task_queue,
255                    workflow_type: (!workflow_type.is_empty()).then_some(workflow_type),
256                    namespace: workflow_namespace,
257                    workflow_id,
258                    workflow_run_id,
259                    activity_id,
260                    activity_type,
261                    heartbeat_timeout: heartbeat_timeout.try_into_or_none(),
262                    scheduled_time: scheduled_time.try_into_or_none(),
263                    started_time: started_time.try_into_or_none(),
264                    deadline,
265                    attempt,
266                    current_attempt_scheduled_time: current_attempt_scheduled_time
267                        .try_into_or_none(),
268                    retry_policy: retry_policy.map(Into::into),
269                    is_local,
270                    priority: priority.map(Into::into).unwrap_or_default(),
271                    activity_run_id,
272                },
273            },
274            input,
275        )
276    }
277
278    /// Returns a future the completes if and when the activity this was called inside has been
279    /// cancelled
280    pub async fn cancelled(&self) {
281        self.cancellation_token.clone().cancelled().await
282    }
283
284    /// Returns true if this activity has already been cancelled
285    pub fn is_cancelled(&self) -> bool {
286        self.cancellation_token.is_cancelled()
287    }
288
289    /// Extract heartbeat details from last failed attempt. This is used in combination with retry
290    /// policy.
291    pub fn heartbeat_details(&self) -> &ActivityHeartbeatDetails {
292        &self.heartbeat_details
293    }
294
295    /// Record a heartbeat with typed progress details for the currently executing activity.
296    pub async fn record_heartbeat<T>(&self, details: T) -> Result<(), PayloadConversionError>
297    where
298        T: TemporalSerializable + 'static,
299    {
300        if !self.info.is_local {
301            self.backend
302                .record_heartbeat(&self.info.task_token, details)
303                .await?;
304        }
305        Ok(())
306    }
307
308    /// Returns activity info of the executing activity
309    pub fn info(&self) -> &ActivityInfo {
310        &self.info
311    }
312
313    /// Return a client targeting the same Temporal service and namespace as this activity's worker.
314    pub fn client(&self) -> Client {
315        self.backend.client()
316    }
317
318    /// Return a workflow handle for the workflow execution that started this activity, if any.
319    pub fn workflow_handle<W: HasWorkflowDefinition>(&self) -> Option<WorkflowHandle<Client, W>> {
320        let workflow_id = self.info.workflow_id.clone()?;
321        let run_id = self.info.workflow_run_id.clone();
322        let first_execution_run_id = run_id.clone();
323        let client = self.client();
324
325        Some(WorkflowHandle::new(
326            client.clone(),
327            WorkflowExecutionInfo {
328                namespace: client.options().namespace.clone(),
329                workflow_id,
330                run_id,
331                first_execution_run_id,
332            },
333        ))
334    }
335
336    /// Get headers attached to this activity
337    pub fn headers(&self) -> &HashMap<String, Payload> {
338        &self.header_fields
339    }
340
341    pub(crate) fn headers_mut(&mut self) -> &mut HashMap<String, Payload> {
342        &mut self.header_fields
343    }
344}
345
346/// Heartbeat details supplied by the previous activity attempt.
347#[derive(Clone, Debug)]
348#[non_exhaustive]
349pub struct ActivityHeartbeatDetails {
350    payloads: DecodablePayloads,
351}
352
353impl ActivityHeartbeatDetails {
354    fn new(payloads: Vec<Payload>, payload_converter: PayloadConverter) -> Self {
355        Self {
356            payloads: DecodablePayloads::new(
357                payloads,
358                payload_converter,
359                SerializationContextData::Activity,
360            ),
361        }
362    }
363
364    /// Deserialize the previous heartbeat details, or return `None` when there are none.
365    pub fn deserialize<T: TemporalDeserializable + 'static>(
366        &self,
367    ) -> Result<Option<T>, PayloadConversionError> {
368        if self.payloads.raw().is_empty() {
369            Ok(None)
370        } else {
371            self.payloads.deserialize().map(Some)
372        }
373    }
374
375    /// Returns the codec-decoded raw heartbeat payloads.
376    pub fn raw(&self) -> &[Payload] {
377        self.payloads.raw()
378    }
379
380    /// Consume these details and return their codec-decoded payloads.
381    pub fn into_raw(self) -> RawValue {
382        self.payloads.into_raw()
383    }
384}
385
386/// Various information about a specific activity attempt.
387#[derive(Clone, Debug)]
388#[non_exhaustive]
389pub struct ActivityInfo {
390    /// An opaque token representing a specific Activity task.
391    pub task_token: Vec<u8>,
392    /// The type of the workflow that invoked this activity. None for standalone activities.
393    pub workflow_type: Option<String>,
394    /// The namespace of this activity.
395    pub namespace: String,
396    /// ID of the workflow that invoked this activity. None for standalone activities.
397    pub workflow_id: Option<String>,
398    /// Run ID of the workflow that invoked this activity. None for standalone activities.
399    pub workflow_run_id: Option<String>,
400    /// The ID of this activity.
401    pub activity_id: String,
402    /// The type of this activity.
403    pub activity_type: String,
404    /// The task queue of this activity.
405    pub task_queue: String,
406    /// The interval within which this activity must heartbeat or be timed out.
407    pub heartbeat_timeout: Option<StdDuration>,
408    /// Time activity was scheduled by a workflow.
409    pub scheduled_time: Option<SystemTime>,
410    /// Time of activity start.
411    pub started_time: Option<SystemTime>,
412    /// Time of activity timeout.
413    pub deadline: Option<SystemTime>,
414    /// Attempt starts from 1, and increase by 1 for every retry, if retry policy is specified.
415    pub attempt: u32,
416    /// Time this attempt at the activity was scheduled.
417    pub current_attempt_scheduled_time: Option<SystemTime>,
418    /// The retry policy for this activity.
419    pub retry_policy: Option<RetryPolicy>,
420    /// Whether or not this is a local activity.
421    pub is_local: bool,
422    /// Priority of this activity. If unset uses [Priority::default].
423    pub priority: Priority,
424    /// Run ID of this activity execution. Only set for standalone activities.
425    pub activity_run_id: Option<String>,
426}
427
428/// Deadline calculation.  This is a port of
429/// https://github.com/temporalio/sdk-go/blob/8651550973088f27f678118f997839fb1bb9e62f/internal/activity.go#L225
430fn calculate_deadline(
431    scheduled_time: Option<&Timestamp>,
432    started_time: Option<&Timestamp>,
433    start_to_close_timeout: Option<&Duration>,
434    schedule_to_close_timeout: Option<&Duration>,
435) -> Option<SystemTime> {
436    match (
437        scheduled_time,
438        started_time,
439        start_to_close_timeout,
440        schedule_to_close_timeout,
441    ) {
442        (
443            Some(scheduled),
444            Some(started),
445            Some(start_to_close_timeout),
446            Some(schedule_to_close_timeout),
447        ) => {
448            let scheduled: SystemTime = maybe_convert_timestamp(scheduled)?;
449            let started: SystemTime = maybe_convert_timestamp(started)?;
450            let start_to_close_timeout: StdDuration = (*start_to_close_timeout).try_into().ok()?;
451            let schedule_to_close_timeout: StdDuration =
452                (*schedule_to_close_timeout).try_into().ok()?;
453
454            let start_to_close_deadline: SystemTime =
455                started.checked_add(start_to_close_timeout)?;
456            if schedule_to_close_timeout > StdDuration::ZERO {
457                let schedule_to_close_deadline =
458                    scheduled.checked_add(schedule_to_close_timeout)?;
459                // Minimum of the two deadlines.
460                if schedule_to_close_deadline < start_to_close_deadline {
461                    Some(schedule_to_close_deadline)
462                } else {
463                    Some(start_to_close_deadline)
464                }
465            } else {
466                Some(start_to_close_deadline)
467            }
468        }
469        _ => None,
470    }
471}
472
473/// Helper function lifted from prost_types::Timestamp implementation to prevent double cloning in
474/// error construction
475fn maybe_convert_timestamp(timestamp: &Timestamp) -> Option<SystemTime> {
476    let mut timestamp = *timestamp;
477    timestamp.normalize();
478
479    let system_time = if timestamp.seconds >= 0 {
480        std::time::UNIX_EPOCH.checked_add(StdDuration::from_secs(timestamp.seconds as u64))
481    } else {
482        std::time::UNIX_EPOCH.checked_sub(StdDuration::from_secs((-timestamp.seconds) as u64))
483    };
484
485    system_time.and_then(|system_time| {
486        system_time.checked_add(StdDuration::from_nanos(timestamp.nanos as u64))
487    })
488}
489
490pub(crate) type ActivityInvocation = Arc<
491    dyn Fn(
492            Vec<Payload>,
493            DataConverter,
494            ActivityContext,
495            Vec<Arc<dyn ActivityInboundInterceptor>>,
496        ) -> ExecuteActivityOutput<'static>
497        + Send
498        + Sync,
499>;
500
501fn call_execute_activity<'a>(
502    interceptors: &'a [Arc<dyn ActivityInboundInterceptor>],
503    input: ExecuteActivityInput,
504    next: Next<'a, ExecuteActivityInput, ExecuteActivityOutput<'a>>,
505) -> ExecuteActivityOutput<'a> {
506    if let Some((first, rest)) = interceptors.split_first() {
507        first.execute_activity(
508            input,
509            Next::new(move |input| call_execute_activity(rest, input, next)),
510        )
511    } else {
512        next.run(input)
513    }
514}
515
516/// Implemented by `#[activities]` for types that provide activity methods.
517///
518/// This trait supports registration and direct execution infrastructure. Applications normally
519/// use the generated implementation rather than implementing it manually.
520pub trait ActivityImplementer {
521    /// Register every activity method implemented by this type.
522    fn register_all(self: Arc<Self>, defs: &mut ActivityDefinitions);
523}
524
525/// Direct execution support generated for each activity marker by `#[activities]`.
526///
527/// Applications normally use the generated implementation rather than implementing this trait
528/// manually.
529pub trait ExecutableActivity: ActivityDefinition + Sized {
530    /// Type containing the activity implementation.
531    type Implementer: ActivityImplementer + Send + Sync + 'static;
532    /// Whether this activity requires an implementation instance.
533    const REQUIRES_INSTANCE: bool;
534    /// Return this activity's definition marker.
535    fn definition() -> Self;
536    /// Execute the activity with already-typed input.
537    fn execute(
538        receiver: Option<Arc<Self::Implementer>>,
539        ctx: ActivityContext,
540        input: Self::Input,
541    ) -> BoxFuture<'static, Result<Self::Output, ActivityError>>;
542}
543
544/// Contains activity registrations in a form ready for execution by workers.
545#[derive(Default, Clone)]
546pub struct ActivityDefinitions {
547    activities: HashMap<String, ActivityInvocation>,
548}
549
550impl ActivityDefinitions {
551    pub(crate) fn extend(&mut self, other: &Self) {
552        self.activities.extend(other.activities.clone());
553    }
554
555    /// Registers all activities on an activity implementer.
556    pub fn register_activities<AI: ActivityImplementer>(&mut self, instance: AI) -> &mut Self {
557        let arcd = Arc::new(instance);
558        AI::register_all(arcd, self);
559        self
560    }
561    /// Registers a specific activitiy.
562    pub fn register_activity<AD>(&mut self, instance: Arc<AD::Implementer>) -> &mut Self
563    where
564        AD: ActivityDefinition + ExecutableActivity,
565        AD::Input: Send + Sync,
566        AD::Output: Send + Sync,
567    {
568        self.activities.insert(
569            AD::definition().name().to_string(),
570            Arc::new(move |payloads, dc, c, activity_inbound_interceptors| {
571                let instance = instance.clone();
572                async move {
573                    // Codec application happens at the SDK/Core boundary, so activity
574                    // implementations work with the payload converter directly.
575                    let pc = dc.payload_converter();
576                    let ctx = SerializationContext {
577                        data: &SerializationContextData::Activity,
578                        converter: pc,
579                    };
580                    let input: AD::Input = pc.from_payloads(&ctx, payloads)?;
581                    let input = ExecuteActivityInput::new(c, Box::new(input));
582                    let leaf = activity_inbound_base::<AD>(instance);
583                    let activity_execution =
584                        call_execute_activity(&activity_inbound_interceptors, input, leaf);
585                    match AssertUnwindSafe(activity_execution).catch_unwind().await {
586                        Ok(output) => output,
587                        Err(panic) => Err(ApplicationFailure::new(anyhow::anyhow!(
588                            "Activity function panicked: {}",
589                            panic_formatter(panic)
590                        ))
591                        .into()),
592                    }
593                }
594                .boxed()
595            }),
596        );
597        self
598    }
599
600    pub(crate) fn is_empty(&self) -> bool {
601        self.activities.is_empty()
602    }
603
604    pub(crate) fn get(&self, act_type: &str) -> Option<ActivityInvocation> {
605        self.activities.get(act_type).cloned()
606    }
607
608    pub(crate) fn names(&self) -> Vec<String> {
609        let mut names: Vec<_> = self.activities.keys().cloned().collect();
610        names.sort_unstable();
611        names
612    }
613}
614
615fn activity_inbound_base<'a, AD>(
616    instance: Arc<AD::Implementer>,
617) -> Next<'a, ExecuteActivityInput, ExecuteActivityOutput<'a>>
618where
619    AD: ActivityDefinition + ExecutableActivity,
620    AD::Input: Send + Sync,
621    AD::Output: Send + Sync,
622{
623    Next::new(
624        move |input: ExecuteActivityInput| -> ExecuteActivityOutput<'a> {
625            let (activity_context, args) = input.into_parts();
626            let args = match args.downcast::<AD::Input>() {
627                Ok(args) => args,
628                Err(_) => {
629                    return ready(Err(ApplicationFailure::new(anyhow::anyhow!(
630                    "Activity inbound interceptor returned arguments with wrong concrete type for activity {}",
631                    AD::definition().name()
632                ))
633                .into()))
634                .boxed();
635                }
636            };
637
638            async move {
639                match AssertUnwindSafe(AD::execute(Some(instance), activity_context, *args))
640                    .catch_unwind()
641                    .await
642                {
643                    Ok(result) => {
644                        result.map(|output| Box::new(output) as Box<dyn ActivityExecutionValue>)
645                    }
646                    Err(panic) => Err(ApplicationFailure::new(anyhow::anyhow!(
647                        "Activity function panicked: {}",
648                        panic_formatter(panic)
649                    ))
650                    .into()),
651                }
652            }
653            .boxed()
654        },
655    )
656}
657
658pub(crate) fn activity_error_to_core_result(
659    dc: &DataConverter,
660    err: ActivityError,
661) -> ActivityExecutionResult {
662    match err {
663        ActivityError::Application(app) => ActivityExecutionResult::fail(dc.to_failure(
664            &SerializationContextData::Activity,
665            OutgoingError::Activity(OutgoingActivityError::Application(app)),
666        )),
667        ActivityError::Cancelled { details } => ActivityExecutionResult::cancel(dc.to_failure(
668            &SerializationContextData::Activity,
669            OutgoingError::Activity(OutgoingActivityError::Cancelled { details }),
670        )),
671        ActivityError::WillCompleteAsync => ActivityExecutionResult::will_complete_async(),
672    }
673}
674
675impl Debug for ActivityDefinitions {
676    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677        f.debug_struct("ActivityDefinitions")
678            .field("activities", &self.activities.keys())
679            .finish()
680    }
681}
682
683#[cfg(test)]
684mod test {
685    use super::*;
686    use rstest::rstest;
687    use temporalio_common::error::{ApplicationErrorCategory, ApplicationFailure};
688
689    #[test]
690    fn activity_heartbeat_details_support_typed_decoding() {
691        let payload_converter = PayloadConverter::default();
692        let payload = payload_converter
693            .to_payload(
694                &SerializationContext {
695                    data: &SerializationContextData::Activity,
696                    converter: &payload_converter,
697                },
698                &"progress".to_owned(),
699            )
700            .unwrap();
701        let details = ActivityHeartbeatDetails::new(vec![payload.clone()], payload_converter);
702
703        assert_eq!(details.raw(), &[payload]);
704        assert_eq!(
705            details.deserialize::<String>().unwrap(),
706            Some("progress".to_owned())
707        );
708    }
709
710    #[test]
711    fn empty_activity_heartbeat_details_decode_to_none() {
712        let details = ActivityHeartbeatDetails::new(Vec::new(), PayloadConverter::default());
713
714        assert_eq!(details.deserialize::<String>().unwrap(), None);
715        assert!(details.into_raw().payloads.is_empty());
716    }
717
718    #[rstest]
719    #[case(true)]
720    #[case(false)]
721    fn activity_error_conversion_is_not_lossy(#[case] non_retryable: bool) {
722        let original = ApplicationFailure::builder(anyhow::anyhow!("big boom"))
723            .type_name("BigBoom".to_owned())
724            .non_retryable(non_retryable)
725            .next_retry_delay(StdDuration::from_secs(3))
726            .category(ApplicationErrorCategory::Benign)
727            .details("details")
728            .build();
729        let err = ActivityError::from(original);
730        let ActivityError::Application(actual) = err else {
731            panic!("application failure should become app failure")
732        };
733        assert_eq!(actual.type_name(), Some("BigBoom"));
734        assert_eq!(actual.is_non_retryable(), non_retryable);
735        assert_eq!(actual.next_retry_delay(), Some(StdDuration::from_secs(3)));
736        assert_eq!(actual.category(), ApplicationErrorCategory::Benign);
737        assert_eq!(actual.to_string(), "big boom");
738    }
739
740    #[test]
741    fn activity_error_from_special_err_becomes_application() {
742        #[derive(Debug, PartialEq)]
743        struct MyError;
744
745        impl std::error::Error for MyError {}
746        impl std::fmt::Display for MyError {
747            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748                f.write_str("MyError")
749            }
750        }
751
752        let err = ActivityError::from(MyError);
753        let ActivityError::Application(actual) = err else {
754            panic!("expected application failure, got {err:?}")
755        };
756        assert_eq!(actual.to_string(), "MyError");
757    }
758}