# Issue #28: 部分的失敗とリカバリーパターンの導入

## 優先度: 高

## 概要

パイプライン内でエラーが発生した際の制御オプションを提供する。Python標準の例外機構を活かしつつ、必要に応じて高度なエラーハンドリングを可能にする。

## 設計方針

**Python の例外機構を最大限活用し、シンプルさを維持する。**

```
┌─────────────────────────────────────────────────────────────┐
│ レベル1: Node内部（個別例外処理）                            │
│   → 各nodeで必要に応じてtry/except                          │
├─────────────────────────────────────────────────────────────┤
│ レベル2: Pipeline（デフォルト）                              │
│   → 例外はそのまま伝播（Python標準動作）                     │
├─────────────────────────────────────────────────────────────┤
│ レベル3: Pipeline（高度な制御）                              │
│   → on_errorで例外マッチ、フォールバック/ログ/再送出         │
└─────────────────────────────────────────────────────────────┘
```

## 現状の問題

```python
# 現状：例外は即座に伝播（これはPython標準で正しい）
result = typed_pipeline(fetch_users, process, save)
# → 例外発生時は呼び出し元に伝播

# 問題：pipeline単位でフォールバックや条件分岐をしたい場合の手段がない
```

## 提案: on_error コールバック

### 基本設計

```python
def typed_pipeline(
    *steps: Callable,
    on_error: Callable[[Exception, str], Any] | None = None,
) -> Any:
    """
    Args:
        *steps: Pipeline steps
        on_error: エラーハンドラー (exception, step_name) -> fallback_value or raise
    """
```

### 使用例

#### レベル1: Node内部で例外処理（従来通り）

```python
@node
def fetch_users():
    """Node内で個別に例外処理"""
    try:
        return api.get_users()
    except NotFoundError:
        return []  # フォールバック値
    # その他の例外は伝播
```

#### レベル2: デフォルト動作（例外伝播）

```python
# on_error 未指定 → Python標準の例外伝播
result = typed_pipeline(fetch_users, process_users, generate_report)
# fetch_users で例外 → そのまま raise（スタックトレース保持）
```

#### レベル3: on_error で高度な制御

```python
def handle_error(error: Exception, step_name: str) -> Any:
    """例外をマッチして細かく制御"""
    match error:
        case ConnectionError() | TimeoutError():
            # 一時的なエラー → キャッシュから取得
            logger.warning(f"{step_name}: {error}, using cache")
            return load_from_cache()

        case NotFoundError():
            # 見つからない → 空のデフォルト値
            return default_empty_result()

        case ValidationError() as e:
            # バリデーションエラー → ログして再送出
            logger.error(f"{step_name}: validation failed: {e}")
            raise

        case _:
            # その他 → 再送出（スタックトレース保持）
            raise

result = typed_pipeline(
    fetch_users,
    process_users,
    generate_report,
    on_error=handle_error
)
```

### on_error の動作仕様

| ハンドラーの動作 | パイプラインの挙動 |
|-----------------|-------------------|
| 値を返す | その値で次のステップを継続 |
| `raise` | 例外を伝播（スタックトレース保持） |
| `raise` 別の例外 | 新しい例外を伝播 |

## 実装計画（TDD）

### Phase 1: Red（テスト先行）

```python
# tests/unit/core/test_on_error.py
import pytest
from railway import node, typed_pipeline


class TestOnErrorCallback:
    def test_no_on_error_propagates_exception(self):
        """on_error 未指定時は例外がそのまま伝播する"""
        @node
        def fail():
            raise ValueError("test error")

        with pytest.raises(ValueError, match="test error"):
            typed_pipeline(fail)

    def test_on_error_can_return_fallback(self):
        """on_error でフォールバック値を返せる"""
        @node
        def fail():
            raise ValueError("test error")

        @node
        def process(x: int) -> int:
            return x * 2

        def handle(error: Exception, step: str) -> int:
            return 10  # フォールバック

        result = typed_pipeline(fail, process, on_error=handle)
        assert result == 20  # 10 * 2

    def test_on_error_can_reraise(self):
        """on_error で再送出できる"""
        @node
        def fail():
            raise ValueError("original")

        def handle(error: Exception, step: str):
            raise  # 再送出

        with pytest.raises(ValueError, match="original"):
            typed_pipeline(fail, on_error=handle)

    def test_on_error_receives_step_name(self):
        """on_error にステップ名が渡される"""
        received_step = None

        @node
        def my_failing_step():
            raise ValueError()

        def handle(error: Exception, step: str):
            nonlocal received_step
            received_step = step
            return "fallback"

        typed_pipeline(my_failing_step, on_error=handle)
        assert received_step == "my_failing_step"

    def test_on_error_match_specific_exception(self):
        """on_error で例外タイプごとに処理を分岐できる"""
        @node
        def fail_connection():
            raise ConnectionError("network")

        @node
        def fail_validation():
            raise ValueError("invalid")

        def handle(error: Exception, step: str):
            match error:
                case ConnectionError():
                    return "cached_data"
                case _:
                    raise

        # ConnectionError → フォールバック
        result = typed_pipeline(fail_connection, on_error=handle)
        assert result == "cached_data"

        # ValueError → 再送出
        with pytest.raises(ValueError):
            typed_pipeline(fail_validation, on_error=handle)

    def test_on_error_preserves_stack_trace(self):
        """on_error で再送出時にスタックトレースが保持される"""
        import traceback

        @node
        def deep_fail():
            def inner():
                raise ValueError("deep error")
            inner()

        def handle(error: Exception, step: str):
            raise

        with pytest.raises(ValueError) as exc_info:
            typed_pipeline(deep_fail, on_error=handle)

        # スタックトレースに inner が含まれる
        tb_str = "".join(traceback.format_tb(exc_info.value.__traceback__))
        assert "inner" in tb_str
```

### Phase 2: Green（最小実装）

```python
# railway/core/pipeline.py
from typing import Callable, Any

def typed_pipeline(
    *steps: Callable,
    on_error: Callable[[Exception, str], Any] | None = None,
) -> Any:
    """型安全なパイプライン実行"""
    result = None

    for step in steps:
        try:
            result = step() if result is None else step(result)
        except Exception as e:
            if on_error is None:
                raise  # デフォルト: 例外伝播
            result = on_error(e, step.__name__)

    return result
```

### Phase 3: Refactor

1. ドキュメント追加
2. TUTORIAL に使用例追加

## 受け入れ条件

- [ ] `on_error` パラメータの実装
- [ ] フォールバック値を返せる
- [ ] `raise` で再送出できる（スタックトレース保持）
- [ ] ステップ名がハンドラーに渡される
- [ ] on_error 未指定時は従来通り例外伝播
- [ ] テスト追加（TDD Red/Green/Refactor）
- [ ] ドキュメント更新

## Result型を採用しない理由

| 観点 | Result型 | on_error（採用） |
|------|----------|------------------|
| Pythonエコシステム親和性 | 低（ラップ必要） | **高（例外そのまま）** |
| スタックトレース | 失われる | **保持される** |
| 学習コスト | 高（Rust/Haskell的） | **低（Python標準）** |
| 既存ライブラリ統合 | 冗長なラップ必要 | **そのまま使える** |
| デバッグ容易性 | 低 | **高** |

## Issue #31, #32 との関係

| 機能 | 用途 | レイヤー |
|------|------|----------|
| try/except（node内） | 個別の例外処理 | Node |
| retry_on (#31) | 一時的エラーの自動リトライ | Node |
| on_error (#28) | pipeline単位のフォールバック | Pipeline |
| on_step (#32) | デバッグ・監査目的 | Pipeline |

## 関連

- 外部監査（2026-01-22）: 優先度 高
- Issue #31: リトライ対象例外指定（retry_on）
- Issue #32: パイプライン中間結果アクセス（on_step）
