diff --git a/README.md b/README.md index 35a4044..2d6e5e6 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,23 @@ impl Task for MyTask { } ``` +### User Errors + +Return user errors with structured data using `TaskError::user()`: + +```rust +// With structured data (message extracted from "message" field if present) +Err(TaskError::user(json!({"message": "Not found", "code": 404}))) + +// With any serializable type +Err(TaskError::user(MyError { code: 404, details: "..." })) + +// Simple string message +Err(TaskError::user_message("Something went wrong")) +``` + +The error data is serialized to JSON and stored in the database for debugging and analysis. + ### TaskContext The [`TaskContext`] provides methods for durable execution: @@ -287,7 +304,9 @@ This is useful when you need to guarantee that a task is only enqueued if relate | [`Task`] | Trait for defining task types | | [`TaskContext`] | Context passed to task execution | | [`TaskResult`] | Result type alias for task returns | -| [`TaskError`] | Error type with control flow signals | +| [`TaskError`] | Error type with control flow signals and user errors | +| [`TaskError::user()`] | Helper to create user errors with JSON data | +| [`TaskError::user_message()`] | Helper to create string user errors | | [`TaskHandle`] | Handle to a spawned subtask (returned by `ctx.spawn()`) | ### Configuration diff --git a/src/error.rs b/src/error.rs index e7d7df4..f55fcfc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -105,10 +105,22 @@ pub enum TaskError { message: String, }, - /// An error from user task code. + /// A user error from task code. /// - /// This is the catch-all variant for errors returned by task implementations. - /// Use `anyhow::anyhow!()` or `?` on any error type to create this variant. + /// This variant stores a serialized user error for persistence and retrieval. + /// Created via [`TaskError::user()`] or [`TaskError::user_message()`]. + #[error("{message}")] + User { + /// The error message (extracted from "message" field or stringified data) + message: String, + /// Serialized error data for storage/retrieval + error_data: JsonValue, + }, + + /// An internal error from user task code. + /// + /// This is the catch-all variant for errors propagated via `?` on anyhow errors. + /// For structured user errors, prefer using [`TaskError::user()`]. #[error(transparent)] TaskInternal(#[from] anyhow::Error), } @@ -118,6 +130,42 @@ pub enum TaskError { /// Use this as the return type for [`Task::run`](crate::Task::run) implementations. pub type TaskResult = Result; +impl TaskError { + /// Create a user error from arbitrary JSON data. + /// + /// If the JSON is an object with a "message" field, that's used for display. + /// Otherwise, the JSON is stringified for the display message. + /// + /// ```ignore + /// // With structured data + /// Err(TaskError::user(json!({"message": "Not found", "code": 404}))) + /// + /// // With any serializable type + /// Err(TaskError::user(MyError { code: 404, details: "..." })) + /// ``` + pub fn user(error_data: impl serde::Serialize) -> Self { + let error_data = serde_json::to_value(&error_data).unwrap_or(serde_json::Value::Null); + let message = error_data + .get("message") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| error_data.to_string()); + TaskError::User { + message, + error_data, + } + } + + /// Create a user error from just a message string. + pub fn user_message(message: impl Into) -> Self { + let message = message.into(); + TaskError::User { + error_data: serde_json::Value::String(message.clone()), + message, + } + } +} + impl From for TaskError { fn from(err: serde_json::Error) -> Self { TaskError::Serialization(err) @@ -192,6 +240,16 @@ pub fn serialize_task_error(err: &TaskError) -> JsonValue { "message": message, }) } + TaskError::User { + message, + error_data, + } => { + serde_json::json!({ + "name": "User", + "message": message, + "error_data": error_data, + }) + } TaskError::TaskInternal(e) => { serde_json::json!({ "name": "TaskInternal", diff --git a/src/task.rs b/src/task.rs index 6d5126e..54f6019 100644 --- a/src/task.rs +++ b/src/task.rs @@ -54,8 +54,10 @@ use crate::error::{TaskError, TaskResult}; /// /// async fn run(url: Self::Params, mut ctx: TaskContext, state: AppState) -> TaskResult { /// let body = ctx.step("fetch", || async { -/// state.http_client.get(&url).send().await?.text().await -/// .map_err(|e| anyhow::anyhow!(e)) +/// state.http_client.get(&url).send().await +/// .map_err(|e| anyhow::anyhow!("HTTP error: {}", e))? +/// .text().await +/// .map_err(|e| anyhow::anyhow!("HTTP error: {}", e)) /// }).await?; /// Ok(body) /// } @@ -82,6 +84,10 @@ where /// Use `?` freely - errors will propagate and the task will be retried /// according to its [`RetryStrategy`](crate::RetryStrategy). /// + /// For user errors with structured data, use `TaskError::user(data)` where + /// data is any serializable value. For simple message errors, use + /// `TaskError::user_message("message")`. + /// /// The [`TaskContext`] provides methods for checkpointing, sleeping, /// and waiting for events. See [`TaskContext`] for details. /// diff --git a/tests/common/tasks.rs b/tests/common/tasks.rs index 91319f7..1a938be 100644 --- a/tests/common/tasks.rs +++ b/tests/common/tasks.rs @@ -120,10 +120,7 @@ impl Task<()> for FailingTask { type Output = (); async fn run(params: Self::Params, _ctx: TaskContext, _state: ()) -> TaskResult { - Err(TaskError::TaskInternal(anyhow::anyhow!( - "{}", - params.error_message - ))) + Err(TaskError::user_message(params.error_message.to_string())) } } @@ -290,9 +287,9 @@ impl Task<()> for StepCountingTask { .await?; if params.fail_after_step2 { - return Err(TaskError::TaskInternal(anyhow::anyhow!( - "Intentional failure after step2" - ))); + return Err(TaskError::user_message( + "Intentional failure after step2".to_string(), + )); } let step3_value: String = ctx @@ -512,9 +509,9 @@ impl Task<()> for FailingChildTask { type Output = (); async fn run(_params: Self::Params, _ctx: TaskContext, _state: ()) -> TaskResult { - Err(TaskError::TaskInternal(anyhow::anyhow!( - "Child task failed intentionally" - ))) + Err(TaskError::user_message( + "Child task failed intentionally".to_string(), + )) } } @@ -1031,9 +1028,7 @@ impl Task<()> for DeterministicReplayTask { }); if should_fail { - return Err(TaskError::TaskInternal(anyhow::anyhow!( - "First attempt failure" - ))); + return Err(TaskError::user_message("First attempt failure".to_string())); } } @@ -1095,9 +1090,9 @@ impl Task<()> for EventThenFailTask { }); if should_fail { - return Err(TaskError::TaskInternal(anyhow::anyhow!( - "First attempt failure after event" - ))); + return Err(TaskError::user_message( + "First attempt failure after event".to_string(), + )); } // Second attempt succeeds with the same payload (from checkpoint) @@ -1241,9 +1236,9 @@ impl Task<()> for SpawnThenFailTask { }); if should_fail { - return Err(TaskError::TaskInternal(anyhow::anyhow!( - "First attempt failure after spawn" - ))); + return Err(TaskError::user_message( + "First attempt failure after spawn".to_string(), + )); } // Second attempt - join child