# Issue #31: リトライポリシーの導入

## 優先度: 中

## 概要

現在の `@node(retries=N)` はすべての例外でリトライするが、特定の例外のみリトライしたい、バックオフ戦略を指定したいなどのニーズがある。**RetryPolicy** オブジェクトを導入し、柔軟なリトライ設定を可能にする。

## 現状の問題

```python
@node(retries=3)
def fetch_data():
    # すべての例外でリトライ（意図しない動作の可能性）
    # バックオフ戦略も指定できない
    ...
```

### 問題のシナリオ

```python
@node(retries=3)
def fetch_users():
    response = requests.get(API_URL)
    if response.status_code == 404:
        raise NotFoundError()  # ← リトライしても無意味
    if response.status_code == 503:
        raise ServiceUnavailableError()  # ← リトライすべき
    return response.json()
```

## 採用: RetryPolicy オブジェクト

```python
from railway import node, RetryPolicy

@node(retry_policy=RetryPolicy(
    max_retries=3,
    retry_on=(ConnectionError, TimeoutError),
    backoff="exponential",
    base_delay=1.0,
))
def fetch_data():
    return requests.get(API_URL).json()
```

### メリット

- **拡張性**: バックオフ戦略、遅延時間など統一的に設定
- **再利用性**: ポリシーオブジェクトを複数ノードで共有
- **明確性**: リトライに関する設定が一箇所にまとまる
- **将来性**: 新しいオプション追加が容易

## RetryPolicy 設計

```python
from dataclasses import dataclass
from typing import Type

@dataclass(frozen=True)
class RetryPolicy:
    """リトライポリシー（イミュータブル）"""

    max_retries: int = 3
    retry_on: tuple[Type[Exception], ...] = (Exception,)
    backoff: str = "fixed"  # "fixed", "linear", "exponential"
    base_delay: float = 1.0  # 秒
    max_delay: float = 60.0  # 秒

    def should_retry(self, exception: Exception, attempt: int) -> bool:
        """リトライすべきか判定"""
        if attempt >= self.max_retries:
            return False
        return isinstance(exception, self.retry_on)

    def get_delay(self, attempt: int) -> float:
        """次のリトライまでの遅延時間を計算"""
        match self.backoff:
            case "fixed":
                delay = self.base_delay
            case "linear":
                delay = self.base_delay * attempt
            case "exponential":
                delay = self.base_delay * (2 ** (attempt - 1))
            case _:
                delay = self.base_delay
        return min(delay, self.max_delay)
```

## 使用例

### 基本的な使用

```python
@node(retry_policy=RetryPolicy(
    max_retries=3,
    retry_on=(ConnectionError, TimeoutError),
))
def fetch_data():
    return requests.get(API_URL).json()
```

### 指数バックオフ

```python
api_retry = RetryPolicy(
    max_retries=5,
    retry_on=(ConnectionError, TimeoutError, HTTPError),
    backoff="exponential",
    base_delay=1.0,
    max_delay=30.0,
)

@node(retry_policy=api_retry)
def fetch_users():
    ...

@node(retry_policy=api_retry)  # ポリシー再利用
def fetch_orders():
    ...
```

### 後方互換性とショートハンド

既存の `retries` パラメータも引き続きサポート。さらに `retry_on` パラメータを追加し、シンプルなケースでは `RetryPolicy` を明示的に作成せずに使える：

```python
# ショートハンド記法（シンプルなケース向け）
@node(retries=3, retry_on=(ConnectionError, TimeoutError))
def simple_retry():
    ...

# 内部的には以下と同等
@node(retry_policy=RetryPolicy(max_retries=3, retry_on=(ConnectionError, TimeoutError)))
def simple_retry():
    ...

# retries のみ指定（後方互換性）
@node(retries=3)
def simple_retry():
    ...
# → RetryPolicy(max_retries=3, retry_on=(Exception,)) と同等
```

**使い分け**:
- シンプルなケース: `@node(retries=N, retry_on=(...))` ショートハンド
- 高度な制御（バックオフ等）: `@node(retry_policy=RetryPolicy(...))` 明示的指定

## 実装計画（TDD）

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

```python
# tests/unit/core/test_retry_policy.py
import pytest
from railway import node, RetryPolicy


class TestRetryPolicy:
    def test_should_retry_on_specified_exception(self):
        """指定した例外でリトライする"""
        policy = RetryPolicy(max_retries=3, retry_on=(ConnectionError,))
        assert policy.should_retry(ConnectionError(), attempt=1) is True
        assert policy.should_retry(ValueError(), attempt=1) is False

    def test_should_not_retry_after_max_retries(self):
        """最大リトライ回数を超えたらリトライしない"""
        policy = RetryPolicy(max_retries=3)
        assert policy.should_retry(Exception(), attempt=3) is False

    def test_exponential_backoff(self):
        """指数バックオフの遅延計算"""
        policy = RetryPolicy(backoff="exponential", base_delay=1.0)
        assert policy.get_delay(1) == 1.0
        assert policy.get_delay(2) == 2.0
        assert policy.get_delay(3) == 4.0

    def test_max_delay_cap(self):
        """最大遅延時間の上限"""
        policy = RetryPolicy(backoff="exponential", base_delay=10.0, max_delay=30.0)
        assert policy.get_delay(10) == 30.0  # 上限でキャップ


class TestNodeWithRetryPolicy:
    def test_retries_on_specified_exception(self):
        """RetryPolicy で指定した例外のみリトライ"""
        attempts = []

        @node(retry_policy=RetryPolicy(max_retries=3, retry_on=(ConnectionError,)))
        def flaky():
            attempts.append(1)
            if len(attempts) < 3:
                raise ConnectionError()
            return "success"

        result = flaky()
        assert result == "success"
        assert len(attempts) == 3

    def test_no_retry_on_unspecified_exception(self):
        """指定外の例外はリトライしない"""
        attempts = []

        @node(retry_policy=RetryPolicy(max_retries=3, retry_on=(ConnectionError,)))
        def fail():
            attempts.append(1)
            raise ValueError("not retryable")

        with pytest.raises(ValueError):
            fail()
        assert len(attempts) == 1

    def test_backward_compatible_retries_param(self):
        """後方互換: retries パラメータも動作する"""
        attempts = []

        @node(retries=3)
        def flaky():
            attempts.append(1)
            if len(attempts) < 3:
                raise ValueError()
            return "success"

        result = flaky()
        assert result == "success"
        assert len(attempts) == 3

    def test_shorthand_retries_with_retry_on(self):
        """ショートハンド: retries + retry_on の組み合わせ"""
        attempts = []

        @node(retries=3, retry_on=(ConnectionError,))
        def flaky():
            attempts.append(1)
            if len(attempts) < 3:
                raise ConnectionError()
            return "success"

        result = flaky()
        assert result == "success"
        assert len(attempts) == 3

    def test_shorthand_does_not_retry_unspecified(self):
        """ショートハンド: retry_on に含まれない例外はリトライしない"""
        attempts = []

        @node(retries=3, retry_on=(ConnectionError,))
        def fail():
            attempts.append(1)
            raise ValueError("not retryable")

        with pytest.raises(ValueError):
            fail()
        assert len(attempts) == 1

    def test_exponential_backoff_timing(self):
        """指数バックオフで遅延が増加する"""
        import time

        start_times = []

        @node(retry_policy=RetryPolicy(
            max_retries=3,
            backoff="exponential",
            base_delay=0.1,
        ))
        def timed_flaky():
            start_times.append(time.time())
            if len(start_times) < 3:
                raise ConnectionError()
            return "success"

        timed_flaky()

        # 2回目は約0.1秒後、3回目は約0.2秒後
        assert start_times[1] - start_times[0] >= 0.09
        assert start_times[2] - start_times[1] >= 0.18
```

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

```python
# railway/core/retry.py
from dataclasses import dataclass
from typing import Type
import time

@dataclass(frozen=True)
class RetryPolicy:
    max_retries: int = 3
    retry_on: tuple[Type[Exception], ...] = (Exception,)
    backoff: str = "fixed"
    base_delay: float = 1.0
    max_delay: float = 60.0

    def should_retry(self, exception: Exception, attempt: int) -> bool:
        if attempt >= self.max_retries:
            return False
        return isinstance(exception, self.retry_on)

    def get_delay(self, attempt: int) -> float:
        match self.backoff:
            case "fixed":
                delay = self.base_delay
            case "linear":
                delay = self.base_delay * attempt
            case "exponential":
                delay = self.base_delay * (2 ** (attempt - 1))
            case _:
                delay = self.base_delay
        return min(delay, self.max_delay)


# railway/core/decorators.py
def _execute_with_retry(func, args, kwargs, policy: RetryPolicy):
    """リトライロジック"""
    last_exception = None
    for attempt in range(policy.max_retries + 1):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            if not policy.should_retry(e, attempt + 1):
                raise
            last_exception = e
            if attempt < policy.max_retries:
                time.sleep(policy.get_delay(attempt + 1))
    raise last_exception
```

### Phase 3: Refactor

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

## 受け入れ条件

- [ ] `RetryPolicy` クラスの実装
- [ ] `should_retry()` メソッド（例外判定）
- [ ] `get_delay()` メソッド（バックオフ計算）
- [ ] `@node(retry_policy=...)` パラメータ
- [ ] 後方互換性（`retries=N` も動作）
- [ ] テスト追加（TDD Red/Green/Refactor）
- [ ] ドキュメント更新

## Issue #28 との関係

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

**推奨パターン**: RetryPolicy で一時的エラーをリトライし、それでも失敗したら on_error で処理

```python
api_policy = RetryPolicy(
    max_retries=3,
    retry_on=(ConnectionError, TimeoutError),
    backoff="exponential",
)

@node(retry_policy=api_policy)
def fetch_data():
    return requests.get(API_URL).json()

def handle_error(error: Exception, step: str):
    match error:
        case ConnectionError():
            return load_from_cache()  # リトライ後も失敗 → キャッシュ
        case _:
            raise

result = typed_pipeline(fetch_data, process, save, on_error=handle_error)
```

## 関連

- 外部監査（2026-01-22）: 優先度 中
- Issue #28: 部分的失敗とリカバリー（on_error）
- tenacity ライブラリの設計パターン
