From 04c11c63e0c89b43af0babc1217009785a8ff110 Mon Sep 17 00:00:00 2001 From: Anish C Date: Tue, 20 Jan 2026 08:43:59 +0100 Subject: [PATCH 1/5] Add panic recovery for task handlers Wrap task handler execution with panic recovery to prevent worker goroutines from crashing when handlers panic. Panics are now captured as PanicError and flow through the normal error handling and retry logic. - Add PanicError type with panic value and stack trace - Add SafeExecute generic function for panic-safe execution - Add SafeHandler and SafeResourceKeyHandler wrappers - Integrate panic recovery into runLoop for all handler types - Add comprehensive unit tests for panic recovery Co-Authored-By: Claude Opus 4.5 --- oncetask/once_task_firestore.go | 6 +- oncetask/panic_recovery.go | 81 +++++++ oncetask/panic_recovery_test.go | 417 ++++++++++++++++++++++++++++++++ 3 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 oncetask/panic_recovery.go create mode 100644 oncetask/panic_recovery_test.go diff --git a/oncetask/once_task_firestore.go b/oncetask/once_task_firestore.go index e7fa513..6e38802 100644 --- a/oncetask/once_task_firestore.go +++ b/oncetask/once_task_firestore.go @@ -271,7 +271,7 @@ func (m *firestoreOnceTaskManager[TaskKind]) runLoop( // Process cancelled tasks individually with cancellation handler if len(cancelledTasks) > 0 { - cancellationHandler := getCancellationHandler[TaskKind](config) + cancellationHandler := SafeHandler(getCancellationHandler[TaskKind](config)) for _, task := range cancelledTasks { ctx := withTaskContext(m.ctx, task.Id, task.ResourceKey) result, execErr := cancellationHandler(ctx, &task) @@ -293,10 +293,10 @@ func (m *firestoreOnceTaskManager[TaskKind]) runLoop( slog.ErrorContext(m.ctx, "Single task handler claimed multiple tasks", "taskType", taskType, "count", len(normalTasks)) execErr = fmt.Errorf("expected 1 task, got %d", len(normalTasks)) } else { - result, execErr = taskHandler(withSingleTaskContext(m.ctx, normalTasks), &normalTasks[0]) + result, execErr = SafeHandler(taskHandler)(withSingleTaskContext(m.ctx, normalTasks), &normalTasks[0]) } } else if hasResource { - result, execErr = resourceHandler(withResourceKeyTaskContext(m.ctx, normalTasks), normalTasks) + result, execErr = SafeResourceKeyHandler(resourceHandler)(withResourceKeyTaskContext(m.ctx, normalTasks), normalTasks) } if err := m.completeBatch(m.ctx, normalTasks, execErr, result, config); err != nil { diff --git a/oncetask/panic_recovery.go b/oncetask/panic_recovery.go new file mode 100644 index 0000000..ad00b45 --- /dev/null +++ b/oncetask/panic_recovery.go @@ -0,0 +1,81 @@ +package oncetask + +import ( + "context" + "fmt" + "runtime/debug" +) + +// PanicError represents an error that occurred due to a panic in a handler. +// It captures the panic value and stack trace for debugging. +type PanicError struct { + // Value is the value that was passed to panic() + Value any + // Stack is the stack trace at the point of panic + Stack string +} + +// Error implements the error interface. +func (e *PanicError) Error() string { + return fmt.Sprintf("panic: %v", e.Value) +} + +// FullError returns the error message with the full stack trace. +func (e *PanicError) FullError() string { + return fmt.Sprintf("panic: %v\n\nstack trace:\n%s", e.Value, e.Stack) +} + +// IsPanicError checks if an error is a PanicError. +func IsPanicError(err error) bool { + _, ok := err.(*PanicError) + return ok +} + +// AsPanicError returns the PanicError if err is one, otherwise nil. +func AsPanicError(err error) *PanicError { + if pe, ok := err.(*PanicError); ok { + return pe + } + return nil +} + +// SafeExecute wraps a function execution with panic recovery. +// If the function panics, the panic is recovered and converted to a PanicError. +// This allows the caller to handle panics as regular errors. +// +// Returns: +// - (result, nil) if fn completes successfully +// - (nil, error) if fn returns an error +// - (nil, *PanicError) if fn panics +func SafeExecute[T any](fn func() (T, error)) (result T, err error) { + defer func() { + if r := recover(); r != nil { + err = &PanicError{ + Value: r, + Stack: string(debug.Stack()), + } + } + }() + + return fn() +} + +// SafeHandler wraps a Handler with panic recovery. +// Returns a new Handler that catches panics and converts them to errors. +func SafeHandler[TaskKind ~string](handler Handler[TaskKind]) Handler[TaskKind] { + return func(ctx context.Context, task *OnceTask[TaskKind]) (any, error) { + return SafeExecute(func() (any, error) { + return handler(ctx, task) + }) + } +} + +// SafeResourceKeyHandler wraps a ResourceKeyHandler with panic recovery. +// Returns a new ResourceKeyHandler that catches panics and converts them to errors. +func SafeResourceKeyHandler[TaskKind ~string](handler ResourceKeyHandler[TaskKind]) ResourceKeyHandler[TaskKind] { + return func(ctx context.Context, tasks []OnceTask[TaskKind]) (any, error) { + return SafeExecute(func() (any, error) { + return handler(ctx, tasks) + }) + } +} diff --git a/oncetask/panic_recovery_test.go b/oncetask/panic_recovery_test.go new file mode 100644 index 0000000..e27f566 --- /dev/null +++ b/oncetask/panic_recovery_test.go @@ -0,0 +1,417 @@ +package oncetask + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestSafeExecute_Success(t *testing.T) { + result, err := SafeExecute(func() (string, error) { + return "success", nil + }) + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != "success" { + t.Errorf("expected 'success', got %q", result) + } +} + +func TestSafeExecute_ReturnsError(t *testing.T) { + expectedErr := errors.New("handler error") + + result, err := SafeExecute(func() (string, error) { + return "", expectedErr + }) + + if err != expectedErr { + t.Errorf("expected %v, got %v", expectedErr, err) + } + if result != "" { + t.Errorf("expected empty result, got %q", result) + } + if IsPanicError(err) { + t.Error("expected regular error, not PanicError") + } +} + +func TestSafeExecute_RecoversPanic_String(t *testing.T) { + result, err := SafeExecute(func() (string, error) { + panic("something went wrong") + }) + + if err == nil { + t.Fatal("expected error, got nil") + } + if result != "" { + t.Errorf("expected empty result, got %q", result) + } + + panicErr := AsPanicError(err) + if panicErr == nil { + t.Fatal("expected PanicError, got different error type") + } + + if panicErr.Value != "something went wrong" { + t.Errorf("expected panic value 'something went wrong', got %v", panicErr.Value) + } + + if !strings.Contains(panicErr.Stack, "panic_recovery_test.go") { + t.Errorf("expected stack trace to contain test file, got:\n%s", panicErr.Stack) + } +} + +func TestSafeExecute_RecoversPanic_Error(t *testing.T) { + panicValue := errors.New("panic with error") + + _, err := SafeExecute(func() (int, error) { + panic(panicValue) + }) + + panicErr := AsPanicError(err) + if panicErr == nil { + t.Fatal("expected PanicError") + } + + if panicErr.Value != panicValue { + t.Errorf("expected panic value to be the error, got %v", panicErr.Value) + } +} + +func TestSafeExecute_RecoversPanic_Int(t *testing.T) { + _, err := SafeExecute(func() (any, error) { + panic(42) + }) + + panicErr := AsPanicError(err) + if panicErr == nil { + t.Fatal("expected PanicError") + } + + if panicErr.Value != 42 { + t.Errorf("expected panic value 42, got %v", panicErr.Value) + } +} + +func TestSafeExecute_RecoversPanic_Nil(t *testing.T) { + _, err := SafeExecute(func() (any, error) { + panic(nil) + }) + + panicErr := AsPanicError(err) + if panicErr == nil { + t.Fatal("expected PanicError") + } + + // Go 1.21+ returns *runtime.PanicNilError for panic(nil) + // Earlier versions returned nil. We just verify it's captured. + if err == nil { + t.Error("expected error to be non-nil") + } +} + +func TestPanicError_Error(t *testing.T) { + pe := &PanicError{ + Value: "test panic", + Stack: "fake stack trace", + } + + expected := "panic: test panic" + if pe.Error() != expected { + t.Errorf("expected %q, got %q", expected, pe.Error()) + } +} + +func TestPanicError_FullError(t *testing.T) { + pe := &PanicError{ + Value: "test panic", + Stack: "line 1\nline 2", + } + + fullErr := pe.FullError() + + if !strings.Contains(fullErr, "panic: test panic") { + t.Errorf("expected full error to contain panic message") + } + if !strings.Contains(fullErr, "stack trace:") { + t.Errorf("expected full error to contain stack trace header") + } + if !strings.Contains(fullErr, "line 1\nline 2") { + t.Errorf("expected full error to contain stack trace content") + } +} + +func TestIsPanicError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "regular error", + err: errors.New("regular error"), + expected: false, + }, + { + name: "panic error", + err: &PanicError{Value: "panic"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsPanicError(tt.err) + if result != tt.expected { + t.Errorf("IsPanicError(%v) = %v, expected %v", tt.err, result, tt.expected) + } + }) + } +} + +func TestAsPanicError(t *testing.T) { + t.Run("returns nil for regular error", func(t *testing.T) { + err := errors.New("regular error") + if AsPanicError(err) != nil { + t.Error("expected nil for regular error") + } + }) + + t.Run("returns nil for nil error", func(t *testing.T) { + if AsPanicError(nil) != nil { + t.Error("expected nil for nil error") + } + }) + + t.Run("returns PanicError for PanicError", func(t *testing.T) { + pe := &PanicError{Value: "test"} + result := AsPanicError(pe) + if result != pe { + t.Errorf("expected same PanicError instance") + } + }) +} + +// TaskKind for testing +type testTaskKind string + +func TestSafeHandler_Success(t *testing.T) { + original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + return "result", nil + } + + safe := SafeHandler(original) + result, err := safe(context.Background(), &OnceTask[testTaskKind]{}) + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != "result" { + t.Errorf("expected 'result', got %v", result) + } +} + +func TestSafeHandler_ReturnsError(t *testing.T) { + expectedErr := errors.New("handler failed") + original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + return nil, expectedErr + } + + safe := SafeHandler(original) + _, err := safe(context.Background(), &OnceTask[testTaskKind]{}) + + if err != expectedErr { + t.Errorf("expected %v, got %v", expectedErr, err) + } +} + +func TestSafeHandler_RecoversPanic(t *testing.T) { + original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + panic("handler panic") + } + + safe := SafeHandler(original) + _, err := safe(context.Background(), &OnceTask[testTaskKind]{}) + + if err == nil { + t.Fatal("expected error from panic") + } + + panicErr := AsPanicError(err) + if panicErr == nil { + t.Fatal("expected PanicError") + } + if panicErr.Value != "handler panic" { + t.Errorf("expected panic value 'handler panic', got %v", panicErr.Value) + } +} + +func TestSafeHandler_PreservesContext(t *testing.T) { + type ctxKey string + key := ctxKey("test-key") + + var capturedValue any + original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + capturedValue = ctx.Value(key) + return nil, nil + } + + safe := SafeHandler(original) + ctx := context.WithValue(context.Background(), key, "test-value") + _, _ = safe(ctx, &OnceTask[testTaskKind]{}) + + if capturedValue != "test-value" { + t.Errorf("expected context value 'test-value', got %v", capturedValue) + } +} + +func TestSafeHandler_PreservesTaskData(t *testing.T) { + var capturedTask *OnceTask[testTaskKind] + original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + capturedTask = task + return nil, nil + } + + safe := SafeHandler(original) + inputTask := &OnceTask[testTaskKind]{Id: "test-id-123"} + _, _ = safe(context.Background(), inputTask) + + if capturedTask != inputTask { + t.Error("expected same task instance to be passed") + } + if capturedTask.Id != "test-id-123" { + t.Errorf("expected task ID 'test-id-123', got %q", capturedTask.Id) + } +} + +func TestSafeResourceKeyHandler_Success(t *testing.T) { + original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + return len(tasks), nil + } + + safe := SafeResourceKeyHandler(original) + tasks := []OnceTask[testTaskKind]{{Id: "1"}, {Id: "2"}, {Id: "3"}} + result, err := safe(context.Background(), tasks) + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != 3 { + t.Errorf("expected result 3, got %v", result) + } +} + +func TestSafeResourceKeyHandler_ReturnsError(t *testing.T) { + expectedErr := errors.New("resource handler failed") + original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + return nil, expectedErr + } + + safe := SafeResourceKeyHandler(original) + _, err := safe(context.Background(), nil) + + if err != expectedErr { + t.Errorf("expected %v, got %v", expectedErr, err) + } +} + +func TestSafeResourceKeyHandler_RecoversPanic(t *testing.T) { + original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + panic("resource handler panic") + } + + safe := SafeResourceKeyHandler(original) + _, err := safe(context.Background(), nil) + + if err == nil { + t.Fatal("expected error from panic") + } + + panicErr := AsPanicError(err) + if panicErr == nil { + t.Fatal("expected PanicError") + } + if panicErr.Value != "resource handler panic" { + t.Errorf("expected panic value 'resource handler panic', got %v", panicErr.Value) + } +} + +func TestSafeResourceKeyHandler_PreservesTasks(t *testing.T) { + var capturedTasks []OnceTask[testTaskKind] + original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + capturedTasks = tasks + return nil, nil + } + + safe := SafeResourceKeyHandler(original) + inputTasks := []OnceTask[testTaskKind]{{Id: "a"}, {Id: "b"}} + _, _ = safe(context.Background(), inputTasks) + + if len(capturedTasks) != 2 { + t.Errorf("expected 2 tasks, got %d", len(capturedTasks)) + } + if capturedTasks[0].Id != "a" || capturedTasks[1].Id != "b" { + t.Error("task data was not preserved") + } +} + +// TestSafeExecute_StackTraceQuality verifies that the stack trace is useful for debugging +func TestSafeExecute_StackTraceQuality(t *testing.T) { + _, err := SafeExecute(func() (any, error) { + nestedPanic() + return nil, nil + }) + + panicErr := AsPanicError(err) + if panicErr == nil { + t.Fatal("expected PanicError") + } + + // Stack should contain the function that panicked + if !strings.Contains(panicErr.Stack, "nestedPanic") { + t.Errorf("expected stack to contain 'nestedPanic', got:\n%s", panicErr.Stack) + } + + // Stack should contain runtime.gopanic (standard Go panic marker) + if !strings.Contains(panicErr.Stack, "panic") { + t.Errorf("expected stack to contain panic marker") + } +} + +func nestedPanic() { + panic("nested panic") +} + +// TestSafeExecute_ComplexResult ensures complex types work correctly +func TestSafeExecute_ComplexResult(t *testing.T) { + type complexResult struct { + Name string + Count int + Items []string + } + + expected := complexResult{ + Name: "test", + Count: 42, + Items: []string{"a", "b", "c"}, + } + + result, err := SafeExecute(func() (complexResult, error) { + return expected, nil + }) + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result.Name != expected.Name || result.Count != expected.Count { + t.Errorf("expected %+v, got %+v", expected, result) + } +} From 38740e26140c445f336233d405a8789fc2398654 Mon Sep 17 00:00:00 2001 From: Anish C Date: Tue, 20 Jan 2026 08:55:46 +0100 Subject: [PATCH 2/5] Simplify panic recovery: remove PanicError type - Remove PanicError struct and helper functions (IsPanicError, AsPanicError) - Log stack trace immediately via slog.ErrorContext instead of storing it - Return simple error with panic value - Update SafeExecute to take context parameter for logging - Simplify tests to match new API Co-Authored-By: Claude Opus 4.5 --- oncetask/panic_recovery.go | 53 ++------- oncetask/panic_recovery_test.go | 201 ++++++-------------------------- 2 files changed, 44 insertions(+), 210 deletions(-) diff --git a/oncetask/panic_recovery.go b/oncetask/panic_recovery.go index ad00b45..3f445c9 100644 --- a/oncetask/panic_recovery.go +++ b/oncetask/panic_recovery.go @@ -3,57 +3,24 @@ package oncetask import ( "context" "fmt" + "log/slog" "runtime/debug" ) -// PanicError represents an error that occurred due to a panic in a handler. -// It captures the panic value and stack trace for debugging. -type PanicError struct { - // Value is the value that was passed to panic() - Value any - // Stack is the stack trace at the point of panic - Stack string -} - -// Error implements the error interface. -func (e *PanicError) Error() string { - return fmt.Sprintf("panic: %v", e.Value) -} - -// FullError returns the error message with the full stack trace. -func (e *PanicError) FullError() string { - return fmt.Sprintf("panic: %v\n\nstack trace:\n%s", e.Value, e.Stack) -} - -// IsPanicError checks if an error is a PanicError. -func IsPanicError(err error) bool { - _, ok := err.(*PanicError) - return ok -} - -// AsPanicError returns the PanicError if err is one, otherwise nil. -func AsPanicError(err error) *PanicError { - if pe, ok := err.(*PanicError); ok { - return pe - } - return nil -} - // SafeExecute wraps a function execution with panic recovery. -// If the function panics, the panic is recovered and converted to a PanicError. -// This allows the caller to handle panics as regular errors. +// If the function panics, the panic is recovered and converted to an error. +// The stack trace is logged via slog.ErrorContext for debugging. // // Returns: // - (result, nil) if fn completes successfully // - (nil, error) if fn returns an error -// - (nil, *PanicError) if fn panics -func SafeExecute[T any](fn func() (T, error)) (result T, err error) { +// - (nil, error) if fn panics (panic converted to error) +func SafeExecute[T any](ctx context.Context, fn func() (T, error)) (result T, err error) { defer func() { if r := recover(); r != nil { - err = &PanicError{ - Value: r, - Stack: string(debug.Stack()), - } + stack := string(debug.Stack()) + slog.ErrorContext(ctx, "handler panicked", "panic", r, "stack", stack) + err = fmt.Errorf("panic: %v", r) } }() @@ -64,7 +31,7 @@ func SafeExecute[T any](fn func() (T, error)) (result T, err error) { // Returns a new Handler that catches panics and converts them to errors. func SafeHandler[TaskKind ~string](handler Handler[TaskKind]) Handler[TaskKind] { return func(ctx context.Context, task *OnceTask[TaskKind]) (any, error) { - return SafeExecute(func() (any, error) { + return SafeExecute(ctx, func() (any, error) { return handler(ctx, task) }) } @@ -74,7 +41,7 @@ func SafeHandler[TaskKind ~string](handler Handler[TaskKind]) Handler[TaskKind] // Returns a new ResourceKeyHandler that catches panics and converts them to errors. func SafeResourceKeyHandler[TaskKind ~string](handler ResourceKeyHandler[TaskKind]) ResourceKeyHandler[TaskKind] { return func(ctx context.Context, tasks []OnceTask[TaskKind]) (any, error) { - return SafeExecute(func() (any, error) { + return SafeExecute(ctx, func() (any, error) { return handler(ctx, tasks) }) } diff --git a/oncetask/panic_recovery_test.go b/oncetask/panic_recovery_test.go index e27f566..e7e1ad7 100644 --- a/oncetask/panic_recovery_test.go +++ b/oncetask/panic_recovery_test.go @@ -8,7 +8,8 @@ import ( ) func TestSafeExecute_Success(t *testing.T) { - result, err := SafeExecute(func() (string, error) { + ctx := context.Background() + result, err := SafeExecute(ctx, func() (string, error) { return "success", nil }) @@ -21,9 +22,10 @@ func TestSafeExecute_Success(t *testing.T) { } func TestSafeExecute_ReturnsError(t *testing.T) { + ctx := context.Background() expectedErr := errors.New("handler error") - result, err := SafeExecute(func() (string, error) { + result, err := SafeExecute(ctx, func() (string, error) { return "", expectedErr }) @@ -33,13 +35,11 @@ func TestSafeExecute_ReturnsError(t *testing.T) { if result != "" { t.Errorf("expected empty result, got %q", result) } - if IsPanicError(err) { - t.Error("expected regular error, not PanicError") - } } func TestSafeExecute_RecoversPanic_String(t *testing.T) { - result, err := SafeExecute(func() (string, error) { + ctx := context.Background() + result, err := SafeExecute(ctx, func() (string, error) { panic("something went wrong") }) @@ -49,157 +49,60 @@ func TestSafeExecute_RecoversPanic_String(t *testing.T) { if result != "" { t.Errorf("expected empty result, got %q", result) } - - panicErr := AsPanicError(err) - if panicErr == nil { - t.Fatal("expected PanicError, got different error type") + if !strings.Contains(err.Error(), "panic:") { + t.Errorf("expected error to contain 'panic:', got %v", err) } - - if panicErr.Value != "something went wrong" { - t.Errorf("expected panic value 'something went wrong', got %v", panicErr.Value) - } - - if !strings.Contains(panicErr.Stack, "panic_recovery_test.go") { - t.Errorf("expected stack trace to contain test file, got:\n%s", panicErr.Stack) + if !strings.Contains(err.Error(), "something went wrong") { + t.Errorf("expected error to contain panic message, got %v", err) } } func TestSafeExecute_RecoversPanic_Error(t *testing.T) { + ctx := context.Background() panicValue := errors.New("panic with error") - _, err := SafeExecute(func() (int, error) { + _, err := SafeExecute(ctx, func() (int, error) { panic(panicValue) }) - panicErr := AsPanicError(err) - if panicErr == nil { - t.Fatal("expected PanicError") + if err == nil { + t.Fatal("expected error") } - - if panicErr.Value != panicValue { - t.Errorf("expected panic value to be the error, got %v", panicErr.Value) + if !strings.Contains(err.Error(), "panic with error") { + t.Errorf("expected error to contain panic message, got %v", err) } } func TestSafeExecute_RecoversPanic_Int(t *testing.T) { - _, err := SafeExecute(func() (any, error) { + ctx := context.Background() + _, err := SafeExecute(ctx, func() (any, error) { panic(42) }) - panicErr := AsPanicError(err) - if panicErr == nil { - t.Fatal("expected PanicError") + if err == nil { + t.Fatal("expected error") } - - if panicErr.Value != 42 { - t.Errorf("expected panic value 42, got %v", panicErr.Value) + if !strings.Contains(err.Error(), "42") { + t.Errorf("expected error to contain '42', got %v", err) } } func TestSafeExecute_RecoversPanic_Nil(t *testing.T) { - _, err := SafeExecute(func() (any, error) { + ctx := context.Background() + _, err := SafeExecute(ctx, func() (any, error) { panic(nil) }) - panicErr := AsPanicError(err) - if panicErr == nil { - t.Fatal("expected PanicError") - } - // Go 1.21+ returns *runtime.PanicNilError for panic(nil) - // Earlier versions returned nil. We just verify it's captured. + // We just verify panic is recovered and converted to an error if err == nil { - t.Error("expected error to be non-nil") + t.Fatal("expected error from panic(nil)") } -} - -func TestPanicError_Error(t *testing.T) { - pe := &PanicError{ - Value: "test panic", - Stack: "fake stack trace", - } - - expected := "panic: test panic" - if pe.Error() != expected { - t.Errorf("expected %q, got %q", expected, pe.Error()) - } -} - -func TestPanicError_FullError(t *testing.T) { - pe := &PanicError{ - Value: "test panic", - Stack: "line 1\nline 2", - } - - fullErr := pe.FullError() - - if !strings.Contains(fullErr, "panic: test panic") { - t.Errorf("expected full error to contain panic message") - } - if !strings.Contains(fullErr, "stack trace:") { - t.Errorf("expected full error to contain stack trace header") - } - if !strings.Contains(fullErr, "line 1\nline 2") { - t.Errorf("expected full error to contain stack trace content") + if !strings.Contains(err.Error(), "panic:") { + t.Errorf("expected error to contain 'panic:', got %v", err) } } -func TestIsPanicError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "nil error", - err: nil, - expected: false, - }, - { - name: "regular error", - err: errors.New("regular error"), - expected: false, - }, - { - name: "panic error", - err: &PanicError{Value: "panic"}, - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsPanicError(tt.err) - if result != tt.expected { - t.Errorf("IsPanicError(%v) = %v, expected %v", tt.err, result, tt.expected) - } - }) - } -} - -func TestAsPanicError(t *testing.T) { - t.Run("returns nil for regular error", func(t *testing.T) { - err := errors.New("regular error") - if AsPanicError(err) != nil { - t.Error("expected nil for regular error") - } - }) - - t.Run("returns nil for nil error", func(t *testing.T) { - if AsPanicError(nil) != nil { - t.Error("expected nil for nil error") - } - }) - - t.Run("returns PanicError for PanicError", func(t *testing.T) { - pe := &PanicError{Value: "test"} - result := AsPanicError(pe) - if result != pe { - t.Errorf("expected same PanicError instance") - } - }) -} - // TaskKind for testing type testTaskKind string @@ -244,13 +147,8 @@ func TestSafeHandler_RecoversPanic(t *testing.T) { if err == nil { t.Fatal("expected error from panic") } - - panicErr := AsPanicError(err) - if panicErr == nil { - t.Fatal("expected PanicError") - } - if panicErr.Value != "handler panic" { - t.Errorf("expected panic value 'handler panic', got %v", panicErr.Value) + if !strings.Contains(err.Error(), "handler panic") { + t.Errorf("expected error to contain 'handler panic', got %v", err) } } @@ -334,13 +232,8 @@ func TestSafeResourceKeyHandler_RecoversPanic(t *testing.T) { if err == nil { t.Fatal("expected error from panic") } - - panicErr := AsPanicError(err) - if panicErr == nil { - t.Fatal("expected PanicError") - } - if panicErr.Value != "resource handler panic" { - t.Errorf("expected panic value 'resource handler panic', got %v", panicErr.Value) + if !strings.Contains(err.Error(), "resource handler panic") { + t.Errorf("expected error to contain 'resource handler panic', got %v", err) } } @@ -363,33 +256,6 @@ func TestSafeResourceKeyHandler_PreservesTasks(t *testing.T) { } } -// TestSafeExecute_StackTraceQuality verifies that the stack trace is useful for debugging -func TestSafeExecute_StackTraceQuality(t *testing.T) { - _, err := SafeExecute(func() (any, error) { - nestedPanic() - return nil, nil - }) - - panicErr := AsPanicError(err) - if panicErr == nil { - t.Fatal("expected PanicError") - } - - // Stack should contain the function that panicked - if !strings.Contains(panicErr.Stack, "nestedPanic") { - t.Errorf("expected stack to contain 'nestedPanic', got:\n%s", panicErr.Stack) - } - - // Stack should contain runtime.gopanic (standard Go panic marker) - if !strings.Contains(panicErr.Stack, "panic") { - t.Errorf("expected stack to contain panic marker") - } -} - -func nestedPanic() { - panic("nested panic") -} - // TestSafeExecute_ComplexResult ensures complex types work correctly func TestSafeExecute_ComplexResult(t *testing.T) { type complexResult struct { @@ -404,7 +270,8 @@ func TestSafeExecute_ComplexResult(t *testing.T) { Items: []string{"a", "b", "c"}, } - result, err := SafeExecute(func() (complexResult, error) { + ctx := context.Background() + result, err := SafeExecute(ctx, func() (complexResult, error) { return expected, nil }) From 3901fa69534caf48f75a30a0043cfc4b17bb1aa2 Mon Sep 17 00:00:00 2001 From: Anish C Date: Tue, 20 Jan 2026 09:02:24 +0100 Subject: [PATCH 3/5] use table level tests --- oncetask/panic_recovery_test.go | 453 +++++++++++++++++--------------- 1 file changed, 242 insertions(+), 211 deletions(-) diff --git a/oncetask/panic_recovery_test.go b/oncetask/panic_recovery_test.go index e7e1ad7..eedf57b 100644 --- a/oncetask/panic_recovery_test.go +++ b/oncetask/panic_recovery_test.go @@ -7,148 +7,194 @@ import ( "testing" ) -func TestSafeExecute_Success(t *testing.T) { - ctx := context.Background() - result, err := SafeExecute(ctx, func() (string, error) { - return "success", nil - }) - - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result != "success" { - t.Errorf("expected 'success', got %q", result) +func TestSafeExecute(t *testing.T) { + tests := []struct { + name string + fn func() (string, error) + wantResult string + wantErr bool + wantErrContain string + }{ + { + name: "success", + fn: func() (string, error) { + return "success", nil + }, + wantResult: "success", + wantErr: false, + }, + { + name: "returns error", + fn: func() (string, error) { + return "", errors.New("handler error") + }, + wantResult: "", + wantErr: true, + wantErrContain: "handler error", + }, + { + name: "recovers panic with string", + fn: func() (string, error) { + panic("something went wrong") + }, + wantResult: "", + wantErr: true, + wantErrContain: "something went wrong", + }, + { + name: "recovers panic with error", + fn: func() (string, error) { + panic(errors.New("panic error")) + }, + wantResult: "", + wantErr: true, + wantErrContain: "panic error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := SafeExecute(context.Background(), tt.fn) + + if result != tt.wantResult { + t.Errorf("result = %q, want %q", result, tt.wantResult) + } + + if (err != nil) != tt.wantErr { + t.Errorf("err = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErrContain != "" && err != nil { + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("err = %v, want containing %q", err, tt.wantErrContain) + } + } + }) } } -func TestSafeExecute_ReturnsError(t *testing.T) { - ctx := context.Background() - expectedErr := errors.New("handler error") - - result, err := SafeExecute(ctx, func() (string, error) { - return "", expectedErr - }) - - if err != expectedErr { - t.Errorf("expected %v, got %v", expectedErr, err) - } - if result != "" { - t.Errorf("expected empty result, got %q", result) - } -} - -func TestSafeExecute_RecoversPanic_String(t *testing.T) { - ctx := context.Background() - result, err := SafeExecute(ctx, func() (string, error) { - panic("something went wrong") - }) - - if err == nil { - t.Fatal("expected error, got nil") - } - if result != "" { - t.Errorf("expected empty result, got %q", result) - } - if !strings.Contains(err.Error(), "panic:") { - t.Errorf("expected error to contain 'panic:', got %v", err) - } - if !strings.Contains(err.Error(), "something went wrong") { - t.Errorf("expected error to contain panic message, got %v", err) +func TestSafeExecute_PanicTypes(t *testing.T) { + tests := []struct { + name string + panicValue any + wantContain string + }{ + { + name: "panic with int", + panicValue: 42, + wantContain: "42", + }, + { + name: "panic with struct", + panicValue: struct{ msg string }{"structured panic"}, + wantContain: "structured panic", + }, + { + name: "panic with nil", + panicValue: nil, + wantContain: "panic:", // Go 1.21+ wraps nil panics + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := SafeExecute(context.Background(), func() (any, error) { + panic(tt.panicValue) + }) + + if err == nil { + t.Fatal("expected error from panic") + } + if !strings.Contains(err.Error(), tt.wantContain) { + t.Errorf("err = %v, want containing %q", err, tt.wantContain) + } + }) } } -func TestSafeExecute_RecoversPanic_Error(t *testing.T) { - ctx := context.Background() - panicValue := errors.New("panic with error") - - _, err := SafeExecute(ctx, func() (int, error) { - panic(panicValue) - }) - - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "panic with error") { - t.Errorf("expected error to contain panic message, got %v", err) +func TestSafeExecute_ComplexResult(t *testing.T) { + type complexResult struct { + Name string + Count int + Items []string } -} -func TestSafeExecute_RecoversPanic_Int(t *testing.T) { - ctx := context.Background() - _, err := SafeExecute(ctx, func() (any, error) { - panic(42) - }) - - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "42") { - t.Errorf("expected error to contain '42', got %v", err) + expected := complexResult{ + Name: "test", + Count: 42, + Items: []string{"a", "b", "c"}, } -} -func TestSafeExecute_RecoversPanic_Nil(t *testing.T) { - ctx := context.Background() - _, err := SafeExecute(ctx, func() (any, error) { - panic(nil) + result, err := SafeExecute(context.Background(), func() (complexResult, error) { + return expected, nil }) - // Go 1.21+ returns *runtime.PanicNilError for panic(nil) - // We just verify panic is recovered and converted to an error - if err == nil { - t.Fatal("expected error from panic(nil)") - } - if !strings.Contains(err.Error(), "panic:") { - t.Errorf("expected error to contain 'panic:', got %v", err) - } -} - -// TaskKind for testing -type testTaskKind string - -func TestSafeHandler_Success(t *testing.T) { - original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - return "result", nil - } - - safe := SafeHandler(original) - result, err := safe(context.Background(), &OnceTask[testTaskKind]{}) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result != "result" { - t.Errorf("expected 'result', got %v", result) + t.Errorf("unexpected error: %v", err) } -} - -func TestSafeHandler_ReturnsError(t *testing.T) { - expectedErr := errors.New("handler failed") - original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - return nil, expectedErr - } - - safe := SafeHandler(original) - _, err := safe(context.Background(), &OnceTask[testTaskKind]{}) - - if err != expectedErr { - t.Errorf("expected %v, got %v", expectedErr, err) + if result.Name != expected.Name || result.Count != expected.Count { + t.Errorf("result = %+v, want %+v", result, expected) } } -func TestSafeHandler_RecoversPanic(t *testing.T) { - original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - panic("handler panic") - } - - safe := SafeHandler(original) - _, err := safe(context.Background(), &OnceTask[testTaskKind]{}) +// testTaskKind for handler tests +type testTaskKind string - if err == nil { - t.Fatal("expected error from panic") - } - if !strings.Contains(err.Error(), "handler panic") { - t.Errorf("expected error to contain 'handler panic', got %v", err) +func TestSafeHandler(t *testing.T) { + tests := []struct { + name string + handler Handler[testTaskKind] + wantResult any + wantErr bool + wantErrContain string + }{ + { + name: "success", + handler: func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + return "result", nil + }, + wantResult: "result", + wantErr: false, + }, + { + name: "returns error", + handler: func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + return nil, errors.New("handler failed") + }, + wantResult: nil, + wantErr: true, + wantErrContain: "handler failed", + }, + { + name: "recovers panic", + handler: func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + panic("handler panic") + }, + wantResult: nil, + wantErr: true, + wantErrContain: "handler panic", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + safe := SafeHandler(tt.handler) + result, err := safe(context.Background(), &OnceTask[testTaskKind]{}) + + if result != tt.wantResult { + t.Errorf("result = %v, want %v", result, tt.wantResult) + } + + if (err != nil) != tt.wantErr { + t.Errorf("err = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErrContain != "" && err != nil { + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("err = %v, want containing %q", err, tt.wantErrContain) + } + } + }) } } @@ -157,128 +203,113 @@ func TestSafeHandler_PreservesContext(t *testing.T) { key := ctxKey("test-key") var capturedValue any - original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + handler := SafeHandler(func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { capturedValue = ctx.Value(key) return nil, nil - } + }) - safe := SafeHandler(original) ctx := context.WithValue(context.Background(), key, "test-value") - _, _ = safe(ctx, &OnceTask[testTaskKind]{}) + _, _ = handler(ctx, &OnceTask[testTaskKind]{}) if capturedValue != "test-value" { - t.Errorf("expected context value 'test-value', got %v", capturedValue) + t.Errorf("context value = %v, want %q", capturedValue, "test-value") } } func TestSafeHandler_PreservesTaskData(t *testing.T) { var capturedTask *OnceTask[testTaskKind] - original := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + handler := SafeHandler(func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { capturedTask = task return nil, nil - } + }) - safe := SafeHandler(original) inputTask := &OnceTask[testTaskKind]{Id: "test-id-123"} - _, _ = safe(context.Background(), inputTask) + _, _ = handler(context.Background(), inputTask) if capturedTask != inputTask { - t.Error("expected same task instance to be passed") + t.Error("expected same task instance") } if capturedTask.Id != "test-id-123" { - t.Errorf("expected task ID 'test-id-123', got %q", capturedTask.Id) + t.Errorf("task ID = %q, want %q", capturedTask.Id, "test-id-123") } } -func TestSafeResourceKeyHandler_Success(t *testing.T) { - original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - return len(tasks), nil - } - - safe := SafeResourceKeyHandler(original) - tasks := []OnceTask[testTaskKind]{{Id: "1"}, {Id: "2"}, {Id: "3"}} - result, err := safe(context.Background(), tasks) - - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result != 3 { - t.Errorf("expected result 3, got %v", result) - } -} - -func TestSafeResourceKeyHandler_ReturnsError(t *testing.T) { - expectedErr := errors.New("resource handler failed") - original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - return nil, expectedErr - } - - safe := SafeResourceKeyHandler(original) - _, err := safe(context.Background(), nil) - - if err != expectedErr { - t.Errorf("expected %v, got %v", expectedErr, err) - } -} - -func TestSafeResourceKeyHandler_RecoversPanic(t *testing.T) { - original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - panic("resource handler panic") - } - - safe := SafeResourceKeyHandler(original) - _, err := safe(context.Background(), nil) - - if err == nil { - t.Fatal("expected error from panic") - } - if !strings.Contains(err.Error(), "resource handler panic") { - t.Errorf("expected error to contain 'resource handler panic', got %v", err) +func TestSafeResourceKeyHandler(t *testing.T) { + tests := []struct { + name string + handler ResourceKeyHandler[testTaskKind] + tasks []OnceTask[testTaskKind] + wantResult any + wantErr bool + wantErrContain string + }{ + { + name: "success", + handler: func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + return len(tasks), nil + }, + tasks: []OnceTask[testTaskKind]{{Id: "1"}, {Id: "2"}, {Id: "3"}}, + wantResult: 3, + wantErr: false, + }, + { + name: "returns error", + handler: func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + return nil, errors.New("resource handler failed") + }, + tasks: nil, + wantResult: nil, + wantErr: true, + wantErrContain: "resource handler failed", + }, + { + name: "recovers panic", + handler: func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + panic("resource handler panic") + }, + tasks: nil, + wantResult: nil, + wantErr: true, + wantErrContain: "resource handler panic", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + safe := SafeResourceKeyHandler(tt.handler) + result, err := safe(context.Background(), tt.tasks) + + if result != tt.wantResult { + t.Errorf("result = %v, want %v", result, tt.wantResult) + } + + if (err != nil) != tt.wantErr { + t.Errorf("err = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErrContain != "" && err != nil { + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("err = %v, want containing %q", err, tt.wantErrContain) + } + } + }) } } func TestSafeResourceKeyHandler_PreservesTasks(t *testing.T) { var capturedTasks []OnceTask[testTaskKind] - original := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + handler := SafeResourceKeyHandler(func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { capturedTasks = tasks return nil, nil - } + }) - safe := SafeResourceKeyHandler(original) inputTasks := []OnceTask[testTaskKind]{{Id: "a"}, {Id: "b"}} - _, _ = safe(context.Background(), inputTasks) + _, _ = handler(context.Background(), inputTasks) if len(capturedTasks) != 2 { - t.Errorf("expected 2 tasks, got %d", len(capturedTasks)) + t.Errorf("captured %d tasks, want 2", len(capturedTasks)) } if capturedTasks[0].Id != "a" || capturedTasks[1].Id != "b" { t.Error("task data was not preserved") } } - -// TestSafeExecute_ComplexResult ensures complex types work correctly -func TestSafeExecute_ComplexResult(t *testing.T) { - type complexResult struct { - Name string - Count int - Items []string - } - - expected := complexResult{ - Name: "test", - Count: 42, - Items: []string{"a", "b", "c"}, - } - - ctx := context.Background() - result, err := SafeExecute(ctx, func() (complexResult, error) { - return expected, nil - }) - - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result.Name != expected.Name || result.Count != expected.Count { - t.Errorf("expected %+v, got %+v", expected, result) - } -} From d39df769f6e80004408f118eac4ee916562609f8 Mon Sep 17 00:00:00 2001 From: Anish C Date: Tue, 20 Jan 2026 09:04:06 +0100 Subject: [PATCH 4/5] Simplify panic recovery to single generic function Replace SafeHandler and SafeResourceKeyHandler wrappers with a single generic SafeExecute[P, R] function that works with any handler signature. Usage: result, err := SafeExecute(ctx, handler, param) This reduces code and makes the panic recovery more flexible - it works with any function matching func(context.Context, P) (R, error). Co-Authored-By: Claude Opus 4.5 --- oncetask/once_task_firestore.go | 8 +- oncetask/panic_recovery.go | 28 +- oncetask/panic_recovery_test.go | 463 ++++++++++++++------------------ 3 files changed, 212 insertions(+), 287 deletions(-) diff --git a/oncetask/once_task_firestore.go b/oncetask/once_task_firestore.go index 6e38802..23f0b55 100644 --- a/oncetask/once_task_firestore.go +++ b/oncetask/once_task_firestore.go @@ -271,10 +271,10 @@ func (m *firestoreOnceTaskManager[TaskKind]) runLoop( // Process cancelled tasks individually with cancellation handler if len(cancelledTasks) > 0 { - cancellationHandler := SafeHandler(getCancellationHandler[TaskKind](config)) + cancellationHandler := getCancellationHandler[TaskKind](config) for _, task := range cancelledTasks { ctx := withTaskContext(m.ctx, task.Id, task.ResourceKey) - result, execErr := cancellationHandler(ctx, &task) + result, execErr := SafeExecute(ctx, cancellationHandler, &task) if err := m.completeBatch(ctx, []OnceTask[TaskKind]{task}, execErr, result, config); err != nil { slog.ErrorContext(ctx, "Failed to complete cancelled task", "error", err, "taskId", task.Id) } @@ -293,10 +293,10 @@ func (m *firestoreOnceTaskManager[TaskKind]) runLoop( slog.ErrorContext(m.ctx, "Single task handler claimed multiple tasks", "taskType", taskType, "count", len(normalTasks)) execErr = fmt.Errorf("expected 1 task, got %d", len(normalTasks)) } else { - result, execErr = SafeHandler(taskHandler)(withSingleTaskContext(m.ctx, normalTasks), &normalTasks[0]) + result, execErr = SafeExecute(withSingleTaskContext(m.ctx, normalTasks), taskHandler, &normalTasks[0]) } } else if hasResource { - result, execErr = SafeResourceKeyHandler(resourceHandler)(withResourceKeyTaskContext(m.ctx, normalTasks), normalTasks) + result, execErr = SafeExecute(withResourceKeyTaskContext(m.ctx, normalTasks), resourceHandler, normalTasks) } if err := m.completeBatch(m.ctx, normalTasks, execErr, result, config); err != nil { diff --git a/oncetask/panic_recovery.go b/oncetask/panic_recovery.go index 3f445c9..a014411 100644 --- a/oncetask/panic_recovery.go +++ b/oncetask/panic_recovery.go @@ -11,11 +11,15 @@ import ( // If the function panics, the panic is recovered and converted to an error. // The stack trace is logged via slog.ErrorContext for debugging. // +// Example usage: +// +// result, err := SafeExecute(ctx, handler, task) +// // Returns: // - (result, nil) if fn completes successfully // - (nil, error) if fn returns an error // - (nil, error) if fn panics (panic converted to error) -func SafeExecute[T any](ctx context.Context, fn func() (T, error)) (result T, err error) { +func SafeExecute[P any, R any](ctx context.Context, fn func(context.Context, P) (R, error), p P) (result R, err error) { defer func() { if r := recover(); r != nil { stack := string(debug.Stack()) @@ -24,25 +28,5 @@ func SafeExecute[T any](ctx context.Context, fn func() (T, error)) (result T, er } }() - return fn() -} - -// SafeHandler wraps a Handler with panic recovery. -// Returns a new Handler that catches panics and converts them to errors. -func SafeHandler[TaskKind ~string](handler Handler[TaskKind]) Handler[TaskKind] { - return func(ctx context.Context, task *OnceTask[TaskKind]) (any, error) { - return SafeExecute(ctx, func() (any, error) { - return handler(ctx, task) - }) - } -} - -// SafeResourceKeyHandler wraps a ResourceKeyHandler with panic recovery. -// Returns a new ResourceKeyHandler that catches panics and converts them to errors. -func SafeResourceKeyHandler[TaskKind ~string](handler ResourceKeyHandler[TaskKind]) ResourceKeyHandler[TaskKind] { - return func(ctx context.Context, tasks []OnceTask[TaskKind]) (any, error) { - return SafeExecute(ctx, func() (any, error) { - return handler(ctx, tasks) - }) - } + return fn(ctx, p) } diff --git a/oncetask/panic_recovery_test.go b/oncetask/panic_recovery_test.go index eedf57b..d85cebc 100644 --- a/oncetask/panic_recovery_test.go +++ b/oncetask/panic_recovery_test.go @@ -7,309 +7,250 @@ import ( "testing" ) -func TestSafeExecute(t *testing.T) { - tests := []struct { - name string - fn func() (string, error) - wantResult string - wantErr bool - wantErrContain string - }{ - { - name: "success", - fn: func() (string, error) { - return "success", nil - }, - wantResult: "success", - wantErr: false, - }, - { - name: "returns error", - fn: func() (string, error) { - return "", errors.New("handler error") - }, - wantResult: "", - wantErr: true, - wantErrContain: "handler error", - }, - { - name: "recovers panic with string", - fn: func() (string, error) { - panic("something went wrong") - }, - wantResult: "", - wantErr: true, - wantErrContain: "something went wrong", - }, - { - name: "recovers panic with error", - fn: func() (string, error) { - panic(errors.New("panic error")) - }, - wantResult: "", - wantErr: true, - wantErrContain: "panic error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := SafeExecute(context.Background(), tt.fn) - - if result != tt.wantResult { - t.Errorf("result = %q, want %q", result, tt.wantResult) - } - - if (err != nil) != tt.wantErr { - t.Errorf("err = %v, wantErr = %v", err, tt.wantErr) - } - - if tt.wantErrContain != "" && err != nil { - if !strings.Contains(err.Error(), tt.wantErrContain) { - t.Errorf("err = %v, want containing %q", err, tt.wantErrContain) - } - } - }) +func TestSafeExecute_Success(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, input string) (string, error) { + return "got: " + input, nil + } + + result, err := SafeExecute(ctx, handler, "test") + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != "got: test" { + t.Errorf("expected 'got: test', got %q", result) } } -func TestSafeExecute_PanicTypes(t *testing.T) { - tests := []struct { - name string - panicValue any - wantContain string - }{ - { - name: "panic with int", - panicValue: 42, - wantContain: "42", - }, - { - name: "panic with struct", - panicValue: struct{ msg string }{"structured panic"}, - wantContain: "structured panic", - }, - { - name: "panic with nil", - panicValue: nil, - wantContain: "panic:", // Go 1.21+ wraps nil panics - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := SafeExecute(context.Background(), func() (any, error) { - panic(tt.panicValue) - }) - - if err == nil { - t.Fatal("expected error from panic") - } - if !strings.Contains(err.Error(), tt.wantContain) { - t.Errorf("err = %v, want containing %q", err, tt.wantContain) - } - }) +func TestSafeExecute_ReturnsError(t *testing.T) { + ctx := context.Background() + expectedErr := errors.New("handler error") + handler := func(ctx context.Context, input string) (string, error) { + return "", expectedErr + } + + result, err := SafeExecute(ctx, handler, "test") + + if err != expectedErr { + t.Errorf("expected %v, got %v", expectedErr, err) + } + if result != "" { + t.Errorf("expected empty result, got %q", result) } } -func TestSafeExecute_ComplexResult(t *testing.T) { - type complexResult struct { - Name string - Count int - Items []string +func TestSafeExecute_RecoversPanic_String(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, input string) (string, error) { + panic("something went wrong") } - expected := complexResult{ - Name: "test", - Count: 42, - Items: []string{"a", "b", "c"}, + result, err := SafeExecute(ctx, handler, "test") + + if err == nil { + t.Fatal("expected error, got nil") + } + if result != "" { + t.Errorf("expected empty result, got %q", result) + } + if !strings.Contains(err.Error(), "panic:") { + t.Errorf("expected error to contain 'panic:', got %v", err) } + if !strings.Contains(err.Error(), "something went wrong") { + t.Errorf("expected error to contain panic message, got %v", err) + } +} - result, err := SafeExecute(context.Background(), func() (complexResult, error) { - return expected, nil - }) +func TestSafeExecute_RecoversPanic_Error(t *testing.T) { + ctx := context.Background() + panicValue := errors.New("panic with error") + handler := func(ctx context.Context, input int) (int, error) { + panic(panicValue) + } - if err != nil { - t.Errorf("unexpected error: %v", err) + _, err := SafeExecute(ctx, handler, 42) + + if err == nil { + t.Fatal("expected error") } - if result.Name != expected.Name || result.Count != expected.Count { - t.Errorf("result = %+v, want %+v", result, expected) + if !strings.Contains(err.Error(), "panic with error") { + t.Errorf("expected error to contain panic message, got %v", err) } } -// testTaskKind for handler tests -type testTaskKind string +func TestSafeExecute_RecoversPanic_Int(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, input any) (any, error) { + panic(42) + } + + _, err := SafeExecute(ctx, handler, nil) + + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "42") { + t.Errorf("expected error to contain '42', got %v", err) + } +} + +func TestSafeExecute_RecoversPanic_Nil(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, input any) (any, error) { + panic(nil) //nolint:govet // Intentionally testing panic(nil) recovery + } + + _, err := SafeExecute(ctx, handler, nil) -func TestSafeHandler(t *testing.T) { - tests := []struct { - name string - handler Handler[testTaskKind] - wantResult any - wantErr bool - wantErrContain string - }{ - { - name: "success", - handler: func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - return "result", nil - }, - wantResult: "result", - wantErr: false, - }, - { - name: "returns error", - handler: func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - return nil, errors.New("handler failed") - }, - wantResult: nil, - wantErr: true, - wantErrContain: "handler failed", - }, - { - name: "recovers panic", - handler: func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - panic("handler panic") - }, - wantResult: nil, - wantErr: true, - wantErrContain: "handler panic", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - safe := SafeHandler(tt.handler) - result, err := safe(context.Background(), &OnceTask[testTaskKind]{}) - - if result != tt.wantResult { - t.Errorf("result = %v, want %v", result, tt.wantResult) - } - - if (err != nil) != tt.wantErr { - t.Errorf("err = %v, wantErr = %v", err, tt.wantErr) - } - - if tt.wantErrContain != "" && err != nil { - if !strings.Contains(err.Error(), tt.wantErrContain) { - t.Errorf("err = %v, want containing %q", err, tt.wantErrContain) - } - } - }) + // Go 1.21+ returns *runtime.PanicNilError for panic(nil) + // We just verify panic is recovered and converted to an error + if err == nil { + t.Fatal("expected error from panic(nil)") + } + if !strings.Contains(err.Error(), "panic:") { + t.Errorf("expected error to contain 'panic:', got %v", err) } } -func TestSafeHandler_PreservesContext(t *testing.T) { +func TestSafeExecute_PreservesContext(t *testing.T) { type ctxKey string key := ctxKey("test-key") var capturedValue any - handler := SafeHandler(func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + handler := func(ctx context.Context, input string) (string, error) { capturedValue = ctx.Value(key) - return nil, nil - }) + return "", nil + } ctx := context.WithValue(context.Background(), key, "test-value") - _, _ = handler(ctx, &OnceTask[testTaskKind]{}) + _, _ = SafeExecute(ctx, handler, "input") if capturedValue != "test-value" { - t.Errorf("context value = %v, want %q", capturedValue, "test-value") + t.Errorf("expected context value 'test-value', got %v", capturedValue) } } -func TestSafeHandler_PreservesTaskData(t *testing.T) { - var capturedTask *OnceTask[testTaskKind] - handler := SafeHandler(func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - capturedTask = task - return nil, nil - }) +func TestSafeExecute_PassesParameter(t *testing.T) { + ctx := context.Background() + var capturedParam int + handler := func(ctx context.Context, input int) (int, error) { + capturedParam = input + return input * 2, nil + } - inputTask := &OnceTask[testTaskKind]{Id: "test-id-123"} - _, _ = handler(context.Background(), inputTask) + result, err := SafeExecute(ctx, handler, 21) - if capturedTask != inputTask { - t.Error("expected same task instance") + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if capturedParam != 21 { + t.Errorf("expected captured param 21, got %d", capturedParam) } - if capturedTask.Id != "test-id-123" { - t.Errorf("task ID = %q, want %q", capturedTask.Id, "test-id-123") + if result != 42 { + t.Errorf("expected result 42, got %d", result) } } -func TestSafeResourceKeyHandler(t *testing.T) { - tests := []struct { - name string - handler ResourceKeyHandler[testTaskKind] - tasks []OnceTask[testTaskKind] - wantResult any - wantErr bool - wantErrContain string - }{ - { - name: "success", - handler: func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - return len(tasks), nil - }, - tasks: []OnceTask[testTaskKind]{{Id: "1"}, {Id: "2"}, {Id: "3"}}, - wantResult: 3, - wantErr: false, - }, - { - name: "returns error", - handler: func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - return nil, errors.New("resource handler failed") - }, - tasks: nil, - wantResult: nil, - wantErr: true, - wantErrContain: "resource handler failed", - }, - { - name: "recovers panic", - handler: func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - panic("resource handler panic") - }, - tasks: nil, - wantResult: nil, - wantErr: true, - wantErrContain: "resource handler panic", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - safe := SafeResourceKeyHandler(tt.handler) - result, err := safe(context.Background(), tt.tasks) - - if result != tt.wantResult { - t.Errorf("result = %v, want %v", result, tt.wantResult) - } - - if (err != nil) != tt.wantErr { - t.Errorf("err = %v, wantErr = %v", err, tt.wantErr) - } - - if tt.wantErrContain != "" && err != nil { - if !strings.Contains(err.Error(), tt.wantErrContain) { - t.Errorf("err = %v, want containing %q", err, tt.wantErrContain) - } - } - }) +func TestSafeExecute_ComplexTypes(t *testing.T) { + type request struct { + Name string + Count int + } + type response struct { + Message string + Items []string + } + + ctx := context.Background() + handler := func(ctx context.Context, req request) (response, error) { + items := make([]string, req.Count) + for i := range items { + items[i] = req.Name + } + return response{Message: "done", Items: items}, nil + } + + result, err := SafeExecute(ctx, handler, request{Name: "test", Count: 3}) + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result.Message != "done" { + t.Errorf("expected message 'done', got %q", result.Message) + } + if len(result.Items) != 3 { + t.Errorf("expected 3 items, got %d", len(result.Items)) } } -func TestSafeResourceKeyHandler_PreservesTasks(t *testing.T) { - var capturedTasks []OnceTask[testTaskKind] - handler := SafeResourceKeyHandler(func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - capturedTasks = tasks - return nil, nil - }) +// TaskKind for testing with actual handler types +type testTaskKind string + +func TestSafeExecute_WithTaskHandler(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + return "processed: " + task.Id, nil + } + + task := &OnceTask[testTaskKind]{Id: "task-123"} + result, err := SafeExecute(ctx, handler, task) + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != "processed: task-123" { + t.Errorf("expected 'processed: task-123', got %v", result) + } +} + +func TestSafeExecute_WithTaskHandler_Panic(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + panic("handler panic") + } + + task := &OnceTask[testTaskKind]{Id: "task-123"} + _, err := SafeExecute(ctx, handler, task) + + if err == nil { + t.Fatal("expected error from panic") + } + if !strings.Contains(err.Error(), "handler panic") { + t.Errorf("expected error to contain 'handler panic', got %v", err) + } +} + +func TestSafeExecute_WithResourceKeyHandler(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + return len(tasks), nil + } + + tasks := []OnceTask[testTaskKind]{{Id: "1"}, {Id: "2"}, {Id: "3"}} + result, err := SafeExecute(ctx, handler, tasks) + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != 3 { + t.Errorf("expected result 3, got %v", result) + } +} + +func TestSafeExecute_WithResourceKeyHandler_Panic(t *testing.T) { + ctx := context.Background() + handler := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + panic("resource handler panic") + } - inputTasks := []OnceTask[testTaskKind]{{Id: "a"}, {Id: "b"}} - _, _ = handler(context.Background(), inputTasks) + tasks := []OnceTask[testTaskKind]{{Id: "1"}} + _, err := SafeExecute(ctx, handler, tasks) - if len(capturedTasks) != 2 { - t.Errorf("captured %d tasks, want 2", len(capturedTasks)) + if err == nil { + t.Fatal("expected error from panic") } - if capturedTasks[0].Id != "a" || capturedTasks[1].Id != "b" { - t.Error("task data was not preserved") + if !strings.Contains(err.Error(), "resource handler panic") { + t.Errorf("expected error to contain 'resource handler panic', got %v", err) } } From d26da522d1c504ac7397010e7aff9bd4e5ca08e1 Mon Sep 17 00:00:00 2001 From: Anish C Date: Tue, 20 Jan 2026 09:30:39 +0100 Subject: [PATCH 5/5] Rewrite panic recovery tests as table-driven tests - Consolidate individual test functions into table-driven tests - Group related test cases: basic execution, panic recovery, handler types - Fix struct field alignment for linter Co-Authored-By: Claude Opus 4.5 --- oncetask/panic_recovery_test.go | 354 +++++++++++++++----------------- 1 file changed, 160 insertions(+), 194 deletions(-) diff --git a/oncetask/panic_recovery_test.go b/oncetask/panic_recovery_test.go index d85cebc..c7f9879 100644 --- a/oncetask/panic_recovery_test.go +++ b/oncetask/panic_recovery_test.go @@ -7,109 +7,110 @@ import ( "testing" ) -func TestSafeExecute_Success(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, input string) (string, error) { - return "got: " + input, nil - } - - result, err := SafeExecute(ctx, handler, "test") - - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result != "got: test" { - t.Errorf("expected 'got: test', got %q", result) - } -} - -func TestSafeExecute_ReturnsError(t *testing.T) { - ctx := context.Background() - expectedErr := errors.New("handler error") - handler := func(ctx context.Context, input string) (string, error) { - return "", expectedErr - } - - result, err := SafeExecute(ctx, handler, "test") - - if err != expectedErr { - t.Errorf("expected %v, got %v", expectedErr, err) - } - if result != "" { - t.Errorf("expected empty result, got %q", result) +func TestSafeExecute(t *testing.T) { + tests := []struct { + handler func(context.Context, string) (string, error) + name string + input string + wantResult string + wantErrContain string + wantErr bool + }{ + { + name: "success returns result", + handler: func(ctx context.Context, input string) (string, error) { + return "got: " + input, nil + }, + input: "test", + wantResult: "got: test", + wantErr: false, + }, + { + name: "error is passed through", + handler: func(ctx context.Context, input string) (string, error) { + return "", errors.New("handler error") + }, + input: "test", + wantResult: "", + wantErr: true, + wantErrContain: "handler error", + }, + { + name: "panic with string is recovered", + handler: func(ctx context.Context, input string) (string, error) { + panic("something went wrong") + }, + input: "test", + wantResult: "", + wantErr: true, + wantErrContain: "something went wrong", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := SafeExecute(context.Background(), tt.handler, tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("SafeExecute() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("SafeExecute() error = %v, want containing %q", err, tt.wantErrContain) + } + if result != tt.wantResult { + t.Errorf("SafeExecute() result = %v, want %v", result, tt.wantResult) + } + }) } } -func TestSafeExecute_RecoversPanic_String(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, input string) (string, error) { - panic("something went wrong") - } - - result, err := SafeExecute(ctx, handler, "test") - - if err == nil { - t.Fatal("expected error, got nil") - } - if result != "" { - t.Errorf("expected empty result, got %q", result) - } - if !strings.Contains(err.Error(), "panic:") { - t.Errorf("expected error to contain 'panic:', got %v", err) - } - if !strings.Contains(err.Error(), "something went wrong") { - t.Errorf("expected error to contain panic message, got %v", err) - } -} - -func TestSafeExecute_RecoversPanic_Error(t *testing.T) { - ctx := context.Background() - panicValue := errors.New("panic with error") - handler := func(ctx context.Context, input int) (int, error) { - panic(panicValue) - } - - _, err := SafeExecute(ctx, handler, 42) - - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "panic with error") { - t.Errorf("expected error to contain panic message, got %v", err) - } -} - -func TestSafeExecute_RecoversPanic_Int(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, input any) (any, error) { - panic(42) - } - - _, err := SafeExecute(ctx, handler, nil) - - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "42") { - t.Errorf("expected error to contain '42', got %v", err) - } -} - -func TestSafeExecute_RecoversPanic_Nil(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, input any) (any, error) { - panic(nil) //nolint:govet // Intentionally testing panic(nil) recovery - } - - _, err := SafeExecute(ctx, handler, nil) - - // Go 1.21+ returns *runtime.PanicNilError for panic(nil) - // We just verify panic is recovered and converted to an error - if err == nil { - t.Fatal("expected error from panic(nil)") - } - if !strings.Contains(err.Error(), "panic:") { - t.Errorf("expected error to contain 'panic:', got %v", err) +func TestSafeExecute_PanicRecovery(t *testing.T) { + tests := []struct { + name string + panicValue any + wantErrContain string + }{ + { + name: "string panic", + panicValue: "panic message", + wantErrContain: "panic message", + }, + { + name: "error panic", + panicValue: errors.New("error as panic"), + wantErrContain: "error as panic", + }, + { + name: "int panic", + panicValue: 42, + wantErrContain: "42", + }, + { + name: "nil panic", + panicValue: nil, + wantErrContain: "panic:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := func(ctx context.Context, input any) (any, error) { + if tt.panicValue == nil { + panic(nil) //nolint:govet // Intentionally testing panic(nil) recovery + } + panic(tt.panicValue) + } + + _, err := SafeExecute(context.Background(), handler, nil) + + if err == nil { + t.Fatal("expected error from panic, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErrContain) + } + }) } } @@ -127,130 +128,95 @@ func TestSafeExecute_PreservesContext(t *testing.T) { _, _ = SafeExecute(ctx, handler, "input") if capturedValue != "test-value" { - t.Errorf("expected context value 'test-value', got %v", capturedValue) + t.Errorf("context value = %v, want %v", capturedValue, "test-value") } } func TestSafeExecute_PassesParameter(t *testing.T) { - ctx := context.Background() var capturedParam int handler := func(ctx context.Context, input int) (int, error) { capturedParam = input return input * 2, nil } - result, err := SafeExecute(ctx, handler, 21) + result, err := SafeExecute(context.Background(), handler, 21) if err != nil { - t.Errorf("expected no error, got %v", err) + t.Errorf("unexpected error: %v", err) } if capturedParam != 21 { - t.Errorf("expected captured param 21, got %d", capturedParam) + t.Errorf("captured param = %d, want 21", capturedParam) } if result != 42 { - t.Errorf("expected result 42, got %d", result) + t.Errorf("result = %d, want 42", result) } } -func TestSafeExecute_ComplexTypes(t *testing.T) { - type request struct { - Name string - Count int - } - type response struct { - Message string - Items []string - } - - ctx := context.Background() - handler := func(ctx context.Context, req request) (response, error) { - items := make([]string, req.Count) - for i := range items { - items[i] = req.Name - } - return response{Message: "done", Items: items}, nil - } - - result, err := SafeExecute(ctx, handler, request{Name: "test", Count: 3}) - - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result.Message != "done" { - t.Errorf("expected message 'done', got %q", result.Message) - } - if len(result.Items) != 3 { - t.Errorf("expected 3 items, got %d", len(result.Items)) - } -} - -// TaskKind for testing with actual handler types +// testTaskKind for testing with actual handler types type testTaskKind string -func TestSafeExecute_WithTaskHandler(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - return "processed: " + task.Id, nil - } +func TestSafeExecute_WithHandlerTypes(t *testing.T) { + t.Run("task handler success", func(t *testing.T) { + handler := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + return "processed: " + task.Id, nil + } - task := &OnceTask[testTaskKind]{Id: "task-123"} - result, err := SafeExecute(ctx, handler, task) + task := &OnceTask[testTaskKind]{Id: "task-123"} + result, err := SafeExecute(context.Background(), handler, task) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result != "processed: task-123" { - t.Errorf("expected 'processed: task-123', got %v", result) - } -} + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != "processed: task-123" { + t.Errorf("result = %v, want %v", result, "processed: task-123") + } + }) -func TestSafeExecute_WithTaskHandler_Panic(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { - panic("handler panic") - } + t.Run("task handler panic", func(t *testing.T) { + handler := func(ctx context.Context, task *OnceTask[testTaskKind]) (any, error) { + panic("handler panic") + } - task := &OnceTask[testTaskKind]{Id: "task-123"} - _, err := SafeExecute(ctx, handler, task) + task := &OnceTask[testTaskKind]{Id: "task-123"} + _, err := SafeExecute(context.Background(), handler, task) - if err == nil { - t.Fatal("expected error from panic") - } - if !strings.Contains(err.Error(), "handler panic") { - t.Errorf("expected error to contain 'handler panic', got %v", err) - } -} + if err == nil { + t.Fatal("expected error from panic") + } + if !strings.Contains(err.Error(), "handler panic") { + t.Errorf("error = %v, want containing %q", err, "handler panic") + } + }) -func TestSafeExecute_WithResourceKeyHandler(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - return len(tasks), nil - } + t.Run("resource key handler success", func(t *testing.T) { + handler := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + return len(tasks), nil + } - tasks := []OnceTask[testTaskKind]{{Id: "1"}, {Id: "2"}, {Id: "3"}} - result, err := SafeExecute(ctx, handler, tasks) + tasks := []OnceTask[testTaskKind]{{Id: "1"}, {Id: "2"}, {Id: "3"}} + result, err := SafeExecute(context.Background(), handler, tasks) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - if result != 3 { - t.Errorf("expected result 3, got %v", result) - } -} + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != 3 { + t.Errorf("result = %v, want 3", result) + } + }) -func TestSafeExecute_WithResourceKeyHandler_Panic(t *testing.T) { - ctx := context.Background() - handler := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { - panic("resource handler panic") - } + t.Run("resource key handler panic", func(t *testing.T) { + handler := func(ctx context.Context, tasks []OnceTask[testTaskKind]) (any, error) { + panic("resource handler panic") + } - tasks := []OnceTask[testTaskKind]{{Id: "1"}} - _, err := SafeExecute(ctx, handler, tasks) + tasks := []OnceTask[testTaskKind]{{Id: "1"}} + _, err := SafeExecute(context.Background(), handler, tasks) - if err == nil { - t.Fatal("expected error from panic") - } - if !strings.Contains(err.Error(), "resource handler panic") { - t.Errorf("expected error to contain 'resource handler panic', got %v", err) - } + if err == nil { + t.Fatal("expected error from panic") + } + if !strings.Contains(err.Error(), "resource handler panic") { + t.Errorf("error = %v, want containing %q", err, "resource handler panic") + } + }) }