File size: 7,236 Bytes
116524e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# Error Handling

The pipeline engine guarantees that every sample produces a `SampleResult` β€” nothing is dropped silently. One failing sample never blocks others. Retry logic is the responsibility of individual steps, not the pipeline.

---

## SampleResult

Every sample that enters `run()` produces exactly one `SampleResult`:

```python

@dataclass

class SampleResult:

    sample: Any                      # the original input

    output: StepContext | None       # final context (None if failed)

    error: Exception | None          # the exception (None if succeeded)

    failed_at: str | None            # step class name where error occurred

    cause: Exception | None = None   # inner exception for BranchError

```

| Field | On success | On failure |
|-------|-----------|------------|
| `sample` | original input | original input |
| `output` | final `StepContext` | `None` |
| `error` | `None` | the exception |
| `failed_at` | `None` | class name of the failing step (e.g. `"Tokenize"`) |
| `cause` | `None` | inner exception when `failed_at == "Branch"` |

!!! note "Background steps"
    For steps after an `async_boundary`, `output` and `error` may still be `None` when `run()` returns. Call `pipe.wait_for_background()` to block until all background work completes and results are finalized.


---

## Construction-time errors

These are caught **before any data flows** β€” they surface immediately when you build the pipeline.

### PipelineOrderError

Raised when a step requires a field that is produced by a **later** step in the pipeline:

```python

from pipeline import Pipeline

from pipeline.errors import PipelineOrderError



class Uppercase:

    requires = frozenset({"tokens"})

    provides = frozenset({"upper_tokens"})

    def __call__(self, ctx): ...



class Tokenize:

    requires = frozenset()

    provides = frozenset({"tokens"})

    def __call__(self, ctx): ...



try:

    Pipeline().then(Uppercase()).then(Tokenize())

except PipelineOrderError as e:

    print(e)

    # Uppercase requires {"tokens"} but it is provided by a later step

```

!!! tip
    `PipelineOrderError` is always a bug β€” reorder your steps. Fields not produced by **any** step in the pipeline are treated as external inputs and do not trigger this error.


### PipelineConfigError

Raised for invalid pipeline wiring:

**Multiple async boundaries:**

```python

from pipeline.errors import PipelineConfigError



class StepA:

    requires = frozenset()

    provides = frozenset({"a"})

    async_boundary = True

    def __call__(self, ctx): ...



class StepB:

    requires = frozenset({"a"})

    provides = frozenset({"b"})

    async_boundary = True

    def __call__(self, ctx): ...



try:

    Pipeline().then(StepA()).then(StepB())

except PipelineConfigError:

    print("Only one async_boundary per pipeline is allowed")

```

**Async boundary inside a Branch child:**

```python

try:

    Pipeline().branch(

        Pipeline().then(StepA()),  # async_boundary = True inside branch

    )

except PipelineConfigError:

    print("async_boundary inside Branch children is not allowed")

```

---

## Runtime errors

### Foreground failures

When a step before the `async_boundary` (or in a pipeline with no boundary) raises an exception, the pipeline catches it per-sample and records it in the `SampleResult`:

```python

class Boom:

    requires = frozenset()

    provides = frozenset()



    def __call__(self, ctx):

        raise RuntimeError(f"Failed on {ctx.sample!r}")



pipe = Pipeline().then(Tokenize()).then(Boom())



results = pipe.run([

    StepContext(sample="good"),

    StepContext(sample="also good"),

])



for r in results:

    if r.error:

        print(f"Sample '{r.sample}' failed at {r.failed_at}: {r.error}")

    else:

        print(f"Sample '{r.sample}' succeeded")

```

```

Sample 'good' failed at Boom: Failed on 'good'

Sample 'also good' failed at Boom: Failed on 'also good'

```

Each sample is processed independently β€” one failure does not prevent others from running.

### Background failures

When a step **after** the `async_boundary` raises, the caller has already moved on. The exception is captured and attached to the `SampleResult` in-place:

```python

pipe = Pipeline().then(Tokenize()).then(BrokenBackgroundStep())



results = pipe.run(samples)

# results returned immediately β€” background still running



pipe.wait_for_background(timeout=10.0)



# Now check for background failures

for r in results:

    if r.error:

        print(f"Background failure at {r.failed_at}: {r.error}")

```

### PipelineCancelled

When a `cancel_token` is triggered, remaining steps and samples are cancelled with `PipelineCancelled`:

```python

from pipeline import CancellationToken, PipelineCancelled



token = CancellationToken()

# ... later, from another thread or endpoint:

token.cancel()



results = pipe.run(contexts, cancel_token=token)



for r in results:

    if isinstance(r.error, PipelineCancelled):

        print(f"Sample '{r.sample}' was cancelled before {r.failed_at}")

    elif r.error:

        print(f"Sample '{r.sample}' failed at {r.failed_at}: {r.error}")

```

Cancellation is checked **between** steps β€” a running step always completes. `PipelineCancelled` follows the same `SampleResult` pattern as step errors: it is caught per-sample, not propagated.

### BranchError

When one or more branch pipelines fail, a `BranchError` is raised with the full list of failures:

```python

from pipeline.errors import BranchError



results = pipe.run(contexts)



for r in results:

    if isinstance(r.error, BranchError):

        print(f"{len(r.error.failures)} branch(es) failed:")

        for f in r.error.failures:

            print(f"  {type(f).__name__}: {f}")

    elif r.error:

        print(f"Step failure at {r.failed_at}: {r.error}")

```

All branches run to completion before `BranchError` is raised β€” no branch is cancelled when another fails. The `SampleResult.cause` field carries the inner exception from the failing branch.

---

## Inspecting results

The standard pattern after `run()`:

```python

results = pipe.run(contexts)

pipe.wait_for_background()  # if using async_boundary



succeeded = [r for r in results if r.error is None]

failed = [r for r in results if r.error is not None]



print(f"{len(succeeded)} succeeded, {len(failed)} failed")



for r in failed:

    print(f"  Sample: {r.sample}")

    print(f"  Failed at: {r.failed_at}")

    print(f"  Error: {r.error}")

```

---

## Background monitoring

### `wait_for_background()`

Blocks until all background tasks complete:

```python

# Wait indefinitely

pipe.wait_for_background()



# Wait with timeout β€” raises TimeoutError if not done

pipe.wait_for_background(timeout=30.0)

```

Completed threads are removed from the tracking list after this call.

### `background_stats()`



Returns a snapshot of background task progress. Thread-safe β€” can be called from any thread while the pipeline is running:



```python

stats = pipe.background_stats()
print(stats)
# {'active': 2, 'completed': 8}
```