Skip to content

Pipeline Error Handling — Future Options

Current State

The platform has two pipeline execution modes:

  1. Legacy state machine (PipelineServiceImpl): Processors return named statuses that branch to other processors via ProcessorConfiguration documents in MongoDB. Supports arbitrary routing, including error/cleanup branches.

  2. Linear executor (PipelineExecutor): Annotation-driven chain sorted by priority. Processors return statuses but only Processor.ERROR_STATUS ("error") is meaningful — it halts the chain. All other statuses are ignored.

The linear executor is simpler and covers most use cases, but it loses the branching/compensation capability of the state machine.

The Problem

When a processor fails (e.g. payment capture), the pipeline halts immediately. There is no mechanism to run cleanup or compensation processors after a failure. Currently, cleanup happens inline within the failing processor itself (e.g. SettlePaymentProcessor sets order.state = PAYMENT_FAILED), but this couples concerns and limits extensibility.

Options

Option 1: Error Handler Chain

Add a second sorted list of processors that run when the main chain halts — analogous to a catch/finally block.

@PipelineStep(pipelines = "submitOrder", priority = 100, phase = "error")
public class SendPaymentFailedNotification extends BaseProcessor { ... }

The executor would collect phase = "error" processors separately and run them (in priority order) when success = false:

if (!success) {
    for (Processor errorHandler : errorProcessors) {
        errorHandler.executeProcessor(siteContext, user, order, processorContext, config);
    }
}

Pros: Simple, no new abstractions, error handlers are just regular processors. Cons: All error handlers run regardless of the error type. No way to target specific failures.

Option 2: Typed Error Statuses with Targeted Handlers

Introduce a hierarchy of error statuses. Processors return a specific error type, and error-phase processors declare which error types they accept.

// Error status hierarchy
public class ErrorStatus {
    public static final String ERROR = "error";
    public static final String AUTH_FAILURE = "error:auth_failure";
    public static final String CAPTURE_FAILURE = "error:capture_failure";
    public static final String PROVIDER_UNAVAILABLE = "error:provider_unavailable";
}

Error-phase processors declare which errors they handle via annotation:

@PipelineStep(
    pipelines = "submitOrder",
    priority = 100,
    phase = "error",
    acceptStatuses = {"error:capture_failure", "error:auth_failure"}
)
public class ReleaseInventoryOnPaymentFailure extends BaseProcessor { ... }

@PipelineStep(
    pipelines = "submitOrder",
    priority = 200,
    phase = "error",
    acceptStatuses = {"error:*"}  // wildcard — runs on any error
)
public class LogPipelineFailure extends BaseProcessor { ... }

The executor matches the returned error status against each error handler's acceptStatuses:

if (!success) {
    String errorStatus = lastResult.getLeft(); // e.g. "error:capture_failure"
    for (Processor handler : errorProcessors) {
        if (handler.accepts(errorStatus)) {
            handler.executeProcessor(...);
        }
    }
}

Status matching rules: - Exact match: "error:capture_failure" matches "error:capture_failure" - Prefix/wildcard: "error:*" matches any error - Plain "error" matches "error" (backwards compatible) - Hierarchical: "error:payment:*" could match "error:payment:capture" and "error:payment:auth"

Pros: Targeted error handling. Different failures trigger different compensation. Extensible — new error types don't require changing existing processors. Cons: More complex. Need to define the status hierarchy. Risk of over-engineering if only a few error types ever exist.

Option 3: Error Context Object

Instead of string statuses, pass a typed error context through the result map:

// In the failing processor
PipelineError error = new CaptureFailureError(order.getId(), "provider_timeout");
return Pair.of(Processor.ERROR_STATUS, Map.of("pipelineError", error));

// Error handlers receive the context
PipelineError error = (PipelineError) processorContext.get("pipelineError");
if (error instanceof CaptureFailureError captureError) {
    // Handle capture-specific cleanup
}

Pros: Rich error context (not just a string). Handlers can inspect error details. Works with existing Pair<String, Map> return type. Cons: Relies on convention (magic key in the map). Type safety depends on instanceof checks.

Option 4: Hybrid — Combine Options 1 + 2

Start with Option 1 (simple error chain) and add acceptStatuses filtering later when needed. The annotation already supports it but the executor ignores it until the feature is needed:

// Phase 1: all error handlers run on any error
@PipelineStep(pipelines = "submitOrder", priority = 100, phase = "error")

// Phase 2 (later): add filtering when needed
@PipelineStep(pipelines = "submitOrder", priority = 100, phase = "error",
    acceptStatuses = {"error:capture_failure"})

Pros: Incremental. Ship the simple version now, add specificity later. Cons: Need to design the annotation upfront even if not using it yet.

Recommendation

Option 4 (Hybrid) is the pragmatic choice:

  1. Add phase = "error" support to PipelineExecutor with a simple "run all error handlers" approach
  2. Add acceptStatuses to @PipelineStep annotation but don't filter on it yet
  3. When a real use case for targeted error handling appears, implement the filtering

This avoids over-engineering while keeping the door open for the typed error status approach.

File Role
commerce-core/.../pipeline/PipelineExecutor.java Linear pipeline executor
commerce-core/.../pipeline/PipelineServiceImpl.java Legacy state-machine executor
commerce-core/.../pipeline/Processor.java ERROR_STATUS constant
commerce-core/.../pipeline/PipelineStep.java Annotation for pipeline registration
commerce-core/.../pipeline/impl/ConditionalCaptureProcessor.java Uses ERROR_STATUS to halt on capture failure
commerce-core/.../pipeline/impl/SettlePaymentProcessor.java Sets PAYMENT_FAILED state inline