Skip to content

Commit e447d83

Browse files
intel352claude
andcommitted
fix: validation step errors return 4xx instead of 500 (closes #359)
Adds a ValidationError type in interfaces/ that HTTP handlers detect and map to the appropriate 4xx status code instead of always returning 500. - interfaces: add ValidationError, NewValidationError, IsValidationError, ValidationErrorStatus helpers - config: add error_status field to PipelineStepConfig - module: add ErrorStatusStep wrapper that converts step errors into ValidationErrors with the configured HTTP status - engine: wire error_status wrapping in buildPipelineSteps alongside the existing skip_if / if guard wrappers - module/http_trigger: detect ValidationError and respond with its status code (400, 422, …) and a JSON error body instead of 500 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5cdbcc1 commit e447d83

7 files changed

Lines changed: 322 additions & 0 deletions

File tree

config/pipeline.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,9 @@ type PipelineStepConfig struct {
3030
// template evaluates to truthy. Falsy or absent with no SkipIf → execute.
3131
// When both SkipIf and If are set, SkipIf takes precedence.
3232
If string `json:"if,omitempty" yaml:"if,omitempty"`
33+
// ErrorStatus overrides the HTTP response status code when this step fails.
34+
// Use 400 for bad requests, 422 for unprocessable entity, etc.
35+
// When set, the step error is wrapped in a ValidationError so the HTTP
36+
// handler returns the specified status code instead of 500.
37+
ErrorStatus int `json:"error_status,omitempty" yaml:"error_status,omitempty"`
3338
}

engine.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,13 @@ func (e *StdEngine) buildPipelineSteps(pipelineName string, stepCfgs []config.Pi
10731073
step = module.NewSkippableStep(step, sc.SkipIf, sc.If)
10741074
}
10751075

1076+
// Wrap the step with an error_status mapper when set so that step
1077+
// failures are surfaced as ValidationErrors and the HTTP handler
1078+
// returns the configured 4xx status instead of 500.
1079+
if sc.ErrorStatus != 0 {
1080+
step = module.NewErrorStatusStep(step, sc.ErrorStatus)
1081+
}
1082+
10761083
steps = append(steps, step)
10771084
}
10781085

interfaces/pipeline.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package interfaces
66

77
import (
88
"context"
9+
"errors"
910
"log/slog"
1011
"maps"
1112
)
@@ -118,6 +119,42 @@ type PipelineStep interface {
118119
Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error)
119120
}
120121

122+
// ValidationError indicates a step failed due to invalid user input, not an
123+
// infrastructure error. HTTP handlers map this to a 4xx status code instead
124+
// of the default 500 Internal Server Error.
125+
type ValidationError struct {
126+
Message string
127+
Status int // HTTP status code to use (e.g., 400, 422)
128+
Field string // optional: which field was invalid
129+
Code string // optional: machine-readable error code for clients
130+
}
131+
132+
func (e *ValidationError) Error() string { return e.Message }
133+
134+
// NewValidationError creates a ValidationError with the given message and HTTP
135+
// status code. Use status 400 for bad input, 422 for unprocessable entity, etc.
136+
func NewValidationError(msg string, status int) *ValidationError {
137+
return &ValidationError{Message: msg, Status: status}
138+
}
139+
140+
// IsValidationError reports whether err (or any error in its chain) is a
141+
// *ValidationError.
142+
func IsValidationError(err error) bool {
143+
var ve *ValidationError
144+
return errors.As(err, &ve)
145+
}
146+
147+
// ValidationErrorStatus returns the HTTP status code from a ValidationError in
148+
// the error chain. Returns 400 if the error has no status or is not a
149+
// ValidationError.
150+
func ValidationErrorStatus(err error) int {
151+
var ve *ValidationError
152+
if errors.As(err, &ve) && ve.Status != 0 {
153+
return ve.Status
154+
}
155+
return 400
156+
}
157+
121158
// StepRegistrar manages step type registration and creation.
122159
// It embeds StepRegistryProvider for type enumeration and adds
123160
// a Create method for instantiating steps. Register is intentionally

module/http_trigger.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414

1515
"github.com/GoCodeAlone/modular"
16+
"github.com/GoCodeAlone/workflow/interfaces"
1617
)
1718

1819
// httpRWContextKey is the unexported type for the HTTP response writer context key.
@@ -436,6 +437,15 @@ func (t *HTTPTrigger) createHandler(route HTTPTriggerRoute) HTTPHandler {
436437
// Call the workflow engine to trigger the workflow
437438
err := t.engine.TriggerWorkflow(ctx, route.Workflow, route.Action, data)
438439
if err != nil {
440+
if interfaces.IsValidationError(err) {
441+
status := interfaces.ValidationErrorStatus(err)
442+
w.Header().Set("Content-Type", "application/json")
443+
w.WriteHeader(status)
444+
if encErr := json.NewEncoder(w).Encode(map[string]any{"error": err.Error()}); encErr != nil {
445+
log.Printf("http trigger: failed to write validation error response: %v", encErr)
446+
}
447+
return
448+
}
439449
http.Error(w, fmt.Sprintf("Error triggering workflow: %v", err), http.StatusInternalServerError)
440450
return
441451
}

module/http_trigger_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package module
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
67
"net/http"
78
"net/http/httptest"
89
"strings"
910
"testing"
1011

1112
"github.com/GoCodeAlone/modular"
13+
"github.com/GoCodeAlone/workflow/interfaces"
1214
)
1315

1416
// TestHTTPTrigger tests the HTTP trigger functionality
@@ -625,3 +627,105 @@ func TestHTTPTrigger_PipelineOutput(t *testing.T) {
625627
t.Errorf("expected status=active, got %v", body["status"])
626628
}
627629
}
630+
631+
// errorEngine is a test engine that always returns a configured error.
632+
type errorEngine struct {
633+
err error
634+
}
635+
636+
func (e *errorEngine) TriggerWorkflow(_ context.Context, _, _ string, _ map[string]any) error {
637+
return e.err
638+
}
639+
640+
// setupErrorEngineRoute is a helper that registers an errorEngine with the
641+
// HTTP trigger and returns the handler for the registered route.
642+
func setupErrorEngineRoute(t *testing.T, eng WorkflowEngine) HTTPHandler {
643+
t.Helper()
644+
app := NewMockApplication()
645+
router := NewMockHTTPRouter("test-router")
646+
_ = app.RegisterService("httpRouter", router)
647+
_ = app.RegisterService("workflowEngine", eng)
648+
649+
trigger := NewHTTPTrigger()
650+
app.RegisterModule(trigger)
651+
cfg := map[string]any{
652+
"routes": []any{
653+
map[string]any{
654+
"path": "/api/validate",
655+
"method": "POST",
656+
"workflow": "test-wf",
657+
"action": "execute",
658+
},
659+
},
660+
}
661+
if err := trigger.Configure(app, cfg); err != nil {
662+
t.Fatalf("Configure: %v", err)
663+
}
664+
if err := trigger.Start(context.Background()); err != nil {
665+
t.Fatalf("Start: %v", err)
666+
}
667+
h, ok := router.routes["POST /api/validate"]
668+
if !ok {
669+
t.Fatal("handler not registered")
670+
}
671+
return h
672+
}
673+
674+
// TestHTTPHandler_ValidationError_Returns400 verifies that when a workflow step
675+
// returns a ValidationError with status 400, the HTTP trigger responds with 400
676+
// and a JSON body containing the error message, not 500.
677+
func TestHTTPHandler_ValidationError_Returns400(t *testing.T) {
678+
valErr := interfaces.NewValidationError("invalid move: card doesn't match", 400)
679+
eng := &errorEngine{err: valErr}
680+
handler := setupErrorEngineRoute(t, eng)
681+
682+
req := httptest.NewRequest("POST", "/api/validate", nil)
683+
w := httptest.NewRecorder()
684+
handler.Handle(w, req)
685+
686+
resp := w.Result()
687+
if resp.StatusCode != 400 {
688+
t.Errorf("expected 400, got %d", resp.StatusCode)
689+
}
690+
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
691+
t.Errorf("expected Content-Type application/json, got %q", ct)
692+
}
693+
var body map[string]any
694+
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
695+
t.Fatalf("failed to decode response body: %v", err)
696+
}
697+
if body["error"] != "invalid move: card doesn't match" {
698+
t.Errorf("unexpected error message: %v", body["error"])
699+
}
700+
}
701+
702+
// TestHTTPHandler_ValidationError_Returns422 verifies that a ValidationError
703+
// with status 422 causes the HTTP trigger to respond with 422.
704+
func TestHTTPHandler_ValidationError_Returns422(t *testing.T) {
705+
valErr := interfaces.NewValidationError("unprocessable input", 422)
706+
eng := &errorEngine{err: valErr}
707+
handler := setupErrorEngineRoute(t, eng)
708+
709+
req := httptest.NewRequest("POST", "/api/validate", nil)
710+
w := httptest.NewRecorder()
711+
handler.Handle(w, req)
712+
713+
if w.Result().StatusCode != 422 {
714+
t.Errorf("expected 422, got %d", w.Result().StatusCode)
715+
}
716+
}
717+
718+
// TestHTTPHandler_InfraError_Returns500 verifies that a non-ValidationError
719+
// still results in a 500 Internal Server Error response.
720+
func TestHTTPHandler_InfraError_Returns500(t *testing.T) {
721+
eng := &errorEngine{err: fmt.Errorf("database connection failed")}
722+
handler := setupErrorEngineRoute(t, eng)
723+
724+
req := httptest.NewRequest("POST", "/api/validate", nil)
725+
w := httptest.NewRecorder()
726+
handler.Handle(w, req)
727+
728+
if w.Result().StatusCode != 500 {
729+
t.Errorf("expected 500, got %d", w.Result().StatusCode)
730+
}
731+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package module
2+
3+
import (
4+
"context"
5+
6+
"github.com/GoCodeAlone/workflow/interfaces"
7+
)
8+
9+
// ErrorStatusStep wraps a PipelineStep and converts any step error into a
10+
// ValidationError with a configured HTTP status code. This allows YAML pipeline
11+
// authors to declare that a step's failure is a client error (4xx), not a
12+
// server error (500), by setting error_status on the step config.
13+
type ErrorStatusStep struct {
14+
inner interfaces.PipelineStep
15+
status int
16+
}
17+
18+
// NewErrorStatusStep wraps inner so that any error it returns is wrapped in a
19+
// ValidationError with the given HTTP status code.
20+
func NewErrorStatusStep(inner interfaces.PipelineStep, status int) *ErrorStatusStep {
21+
return &ErrorStatusStep{inner: inner, status: status}
22+
}
23+
24+
// Name delegates to the wrapped step.
25+
func (s *ErrorStatusStep) Name() string {
26+
return s.inner.Name()
27+
}
28+
29+
// Execute runs the wrapped step. If it returns an error that is not already a
30+
// ValidationError, the error is wrapped in one with the configured status code.
31+
func (s *ErrorStatusStep) Execute(ctx context.Context, pc *PipelineContext) (*interfaces.StepResult, error) {
32+
result, err := s.inner.Execute(ctx, pc)
33+
if err != nil {
34+
if !interfaces.IsValidationError(err) {
35+
return nil, interfaces.NewValidationError(err.Error(), s.status)
36+
}
37+
return nil, err
38+
}
39+
return result, nil
40+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package module
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/GoCodeAlone/workflow/interfaces"
9+
)
10+
11+
// fixedErrorStep is a test step that always returns a configured error.
12+
type fixedErrorStep struct {
13+
name string
14+
err error
15+
}
16+
17+
func (s *fixedErrorStep) Name() string { return s.name }
18+
func (s *fixedErrorStep) Execute(_ context.Context, _ *PipelineContext) (*interfaces.StepResult, error) {
19+
return nil, s.err
20+
}
21+
22+
// fixedSuccessStep is a test step that always succeeds.
23+
type fixedSuccessStep struct {
24+
name string
25+
}
26+
27+
func (s *fixedSuccessStep) Name() string { return s.name }
28+
func (s *fixedSuccessStep) Execute(_ context.Context, _ *PipelineContext) (*interfaces.StepResult, error) {
29+
return &interfaces.StepResult{Output: map[string]any{"ok": true}}, nil
30+
}
31+
32+
// TestErrorStatusStep_WrapsErrorWithValidationError verifies that a plain error
33+
// returned by the inner step is wrapped in a ValidationError with the configured
34+
// HTTP status code.
35+
func TestErrorStatusStep_WrapsErrorWithValidationError(t *testing.T) {
36+
inner := &fixedErrorStep{name: "validate", err: errors.New("card doesn't match")}
37+
step := NewErrorStatusStep(inner, 400)
38+
39+
_, err := step.Execute(context.Background(), &PipelineContext{
40+
Current: map[string]any{},
41+
Metadata: map[string]any{},
42+
})
43+
if err == nil {
44+
t.Fatal("expected error, got nil")
45+
}
46+
if !interfaces.IsValidationError(err) {
47+
t.Fatalf("expected ValidationError, got %T: %v", err, err)
48+
}
49+
if got := interfaces.ValidationErrorStatus(err); got != 400 {
50+
t.Errorf("expected status 400, got %d", got)
51+
}
52+
if err.Error() != "card doesn't match" {
53+
t.Errorf("unexpected error message: %q", err.Error())
54+
}
55+
}
56+
57+
// TestErrorStatusStep_PassesThroughExistingValidationError verifies that a
58+
// ValidationError already set by the inner step is not double-wrapped.
59+
func TestErrorStatusStep_PassesThroughExistingValidationError(t *testing.T) {
60+
inner := &fixedErrorStep{
61+
name: "validate",
62+
err: interfaces.NewValidationError("already a validation error", 422),
63+
}
64+
step := NewErrorStatusStep(inner, 400)
65+
66+
_, err := step.Execute(context.Background(), &PipelineContext{
67+
Current: map[string]any{},
68+
Metadata: map[string]any{},
69+
})
70+
if err == nil {
71+
t.Fatal("expected error, got nil")
72+
}
73+
// Should keep the original 422, not override with 400
74+
if got := interfaces.ValidationErrorStatus(err); got != 422 {
75+
t.Errorf("expected original status 422 preserved, got %d", got)
76+
}
77+
}
78+
79+
// TestErrorStatusStep_SuccessPassesThrough verifies that a successful step
80+
// result is returned unchanged.
81+
func TestErrorStatusStep_SuccessPassesThrough(t *testing.T) {
82+
inner := &fixedSuccessStep{name: "ok-step"}
83+
step := NewErrorStatusStep(inner, 400)
84+
85+
result, err := step.Execute(context.Background(), &PipelineContext{
86+
Current: map[string]any{},
87+
Metadata: map[string]any{},
88+
})
89+
if err != nil {
90+
t.Fatalf("unexpected error: %v", err)
91+
}
92+
if result == nil || result.Output["ok"] != true {
93+
t.Errorf("expected successful result with ok=true, got %v", result)
94+
}
95+
}
96+
97+
// TestStepConfig_ErrorStatus verifies that a pipeline with error_status on a
98+
// step surfaces the configured 4xx status when the step fails.
99+
func TestStepConfig_ErrorStatus(t *testing.T) {
100+
inner := &fixedErrorStep{name: "validate", err: errors.New("invalid input")}
101+
step := NewErrorStatusStep(inner, 400)
102+
103+
p := &Pipeline{
104+
Name: "test-pipeline",
105+
Steps: []PipelineStep{step},
106+
OnError: ErrorStrategyStop,
107+
}
108+
109+
_, err := p.Execute(context.Background(), map[string]any{})
110+
if err == nil {
111+
t.Fatal("expected error from pipeline")
112+
}
113+
if !interfaces.IsValidationError(err) {
114+
t.Fatalf("expected ValidationError in pipeline error chain, got %T: %v", err, err)
115+
}
116+
if got := interfaces.ValidationErrorStatus(err); got != 400 {
117+
t.Errorf("expected status 400 from pipeline error, got %d", got)
118+
}
119+
}

0 commit comments

Comments
 (0)