# Issue #11: リトライ機能（tenacity統合）

**Phase:** 1b
**優先度:** 高
**依存関係:** #03, #06
**見積もり:** 1日

---

## 概要

tenacityライブラリを使ったリトライ機能を@nodeデコレータに統合する。
設定ファイルからリトライポリシーを読み込み、ノードごとにカスタマイズ可能にする。

---

## TDD実装手順

### Step 1: Red（テストを書く）

```python
# tests/unit/core/test_retry.py
"""Tests for retry functionality."""
import pytest
from unittest.mock import patch, MagicMock


class TestNodeRetry:
    """Test @node decorator retry functionality."""

    def test_node_with_retry_enabled(self):
        """Should retry on failure when retry=True."""
        from railway.core.decorators import node

        call_count = 0

        @node(retry=True)
        def flaky_node() -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise ValueError("Temporary failure")
            return "success"

        with patch("railway.core.decorators.logger"):
            with patch("railway.core.config.get_retry_config") as mock_config:
                mock_settings = MagicMock()
                mock_settings.max_attempts = 5
                mock_settings.min_wait = 0.01
                mock_settings.max_wait = 0.02
                mock_settings.multiplier = 1
                mock_config.return_value = mock_settings

                result = flaky_node()

        assert result == "success"
        assert call_count == 3

    def test_node_retry_exhausted(self):
        """Should raise exception after max retries."""
        from railway.core.decorators import node

        call_count = 0

        @node(retry=True)
        def always_fails() -> str:
            nonlocal call_count
            call_count += 1
            raise ValueError("Always fails")

        with patch("railway.core.decorators.logger"):
            with patch("railway.core.config.get_retry_config") as mock_config:
                mock_settings = MagicMock()
                mock_settings.max_attempts = 3
                mock_settings.min_wait = 0.01
                mock_settings.max_wait = 0.02
                mock_settings.multiplier = 1
                mock_config.return_value = mock_settings

                with pytest.raises(ValueError, match="Always fails"):
                    always_fails()

        assert call_count == 3

    def test_node_retry_false(self):
        """Should not retry when retry=False."""
        from railway.core.decorators import node

        call_count = 0

        @node(retry=False)
        def no_retry_node() -> str:
            nonlocal call_count
            call_count += 1
            raise ValueError("Fails")

        with patch("railway.core.decorators.logger"):
            with pytest.raises(ValueError):
                no_retry_node()

        assert call_count == 1

    def test_node_custom_retry_config(self):
        """Should use custom Retry configuration."""
        from railway.core.decorators import node, Retry

        call_count = 0

        @node(retry=Retry(max_attempts=5, min_wait=0.01, max_wait=0.02))
        def custom_retry_node() -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 4:
                raise ValueError("Temp failure")
            return "done"

        with patch("railway.core.decorators.logger"):
            result = custom_retry_node()

        assert result == "done"
        assert call_count == 4


class TestRetryLogging:
    """Test retry logging."""

    def test_retry_logs_attempts(self):
        """Should log retry attempts."""
        from railway.core.decorators import node

        call_count = 0

        @node(retry=True)
        def retry_with_log() -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise ValueError("Temp")
            return "ok"

        with patch("railway.core.decorators.logger") as mock_logger:
            with patch("railway.core.config.get_retry_config") as mock_config:
                mock_settings = MagicMock()
                mock_settings.max_attempts = 3
                mock_settings.min_wait = 0.01
                mock_settings.max_wait = 0.02
                mock_settings.multiplier = 1
                mock_config.return_value = mock_settings

                retry_with_log()

        # Should have warning for retry attempt
        warning_calls = [str(c) for c in mock_logger.warning.call_args_list]
        assert any("リトライ" in str(c) or "retry" in str(c).lower() for c in warning_calls)


class TestRetryWithSettings:
    """Test retry with settings integration."""

    def test_retry_uses_node_specific_settings(self):
        """Should use node-specific retry settings."""
        from railway.core.decorators import node

        call_count = 0

        @node(retry=True, name="special_node")
        def special_node() -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise ValueError("Temp")
            return "done"

        with patch("railway.core.decorators.logger"):
            with patch("railway.core.config.get_retry_config") as mock_config:
                mock_settings = MagicMock()
                mock_settings.max_attempts = 5
                mock_settings.min_wait = 0.01
                mock_settings.max_wait = 0.02
                mock_settings.multiplier = 1
                mock_config.return_value = mock_settings

                special_node()

                # Should be called with node name
                mock_config.assert_called_with("special_node")
```

```bash
# 実行して失敗を確認
pytest tests/unit/core/test_retry.py -v
# Expected: FAILED
```

### Step 2: Green（最小限の実装）

```python
# railway/core/decorators.py を更新（retry機能追加）

from functools import wraps
from typing import Callable, TypeVar, ParamSpec, Any
from loguru import logger
from tenacity import retry, stop_after_attempt, wait_exponential, RetryError

P = ParamSpec('P')
T = TypeVar('T')


class Retry:
    """Retry configuration for nodes."""

    def __init__(
        self,
        max_attempts: int = 3,
        min_wait: float = 2.0,
        max_wait: float = 10.0,
        exponential_base: int = 2,
    ):
        self.max_attempts = max_attempts
        self.min_wait = min_wait
        self.max_wait = max_wait
        self.exponential_base = exponential_base


def node(
    func: Callable[P, T] | None = None,
    *,
    retry: bool | Retry = False,
    log_input: bool = False,
    log_output: bool = False,
    name: str | None = None,
) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]:
    """
    Node decorator with retry support.
    """

    def decorator(f: Callable[P, T]) -> Callable[P, T]:
        node_name = name or f.__name__

        @wraps(f)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
            # Log input if enabled
            if log_input:
                logger.debug(f"[{node_name}] Input: args={args}, kwargs={kwargs}")

            logger.info(f"[{node_name}] Starting...")

            # Prepare retry decorator if needed
            if retry is True:
                # Load retry settings from config
                from railway.core.config import get_retry_config
                retry_config = get_retry_config(node_name)
                max_attempts = retry_config.max_attempts

                def before_retry(retry_state):
                    attempt = retry_state.attempt_number
                    logger.warning(f"[{node_name}] リトライ中... (試行 {attempt}/{max_attempts})")

                retry_decorator = tenacity_retry(
                    stop=stop_after_attempt(max_attempts),
                    wait=wait_exponential(
                        multiplier=retry_config.multiplier,
                        min=retry_config.min_wait,
                        max=retry_config.max_wait,
                    ),
                    reraise=True,
                    before=before_retry,
                )
                retryable_func = retry_decorator(f)

            elif isinstance(retry, Retry):
                max_attempts = retry.max_attempts

                def before_retry(retry_state):
                    attempt = retry_state.attempt_number
                    logger.warning(f"[{node_name}] リトライ中... (試行 {attempt}/{max_attempts})")

                retry_decorator = tenacity_retry(
                    stop=stop_after_attempt(retry.max_attempts),
                    wait=wait_exponential(
                        multiplier=retry.exponential_base,
                        min=retry.min_wait,
                        max=retry.max_wait,
                    ),
                    reraise=True,
                    before=before_retry,
                )
                retryable_func = retry_decorator(f)
            else:
                retryable_func = f

            try:
                result = retryable_func(*args, **kwargs)

                # Log output if enabled
                if log_output:
                    logger.debug(f"[{node_name}] Output: {result}")

                logger.info(f"[{node_name}] ✓ Completed")
                return result

            except RetryError as e:
                original_exception = e.last_attempt.exception()
                logger.error(
                    f"[{node_name}] ✗ Failed after retries: "
                    f"{type(original_exception).__name__}: {original_exception}"
                )
                raise original_exception

            except Exception as e:
                logger.error(f"[{node_name}] ✗ Failed: {type(e).__name__}: {e}")
                raise

        # Store metadata
        wrapper._is_railway_node = True
        wrapper._node_name = node_name
        wrapper._original_func = f
        wrapper._is_async = False

        return wrapper

    if func is None:
        return decorator
    return decorator(func)


# Alias for tenacity retry to avoid naming conflict
from tenacity import retry as tenacity_retry
```

```bash
# 実行して成功を確認
pytest tests/unit/core/test_retry.py -v
# Expected: PASSED
```

---

## 完了条件

- [ ] `@node(retry=True)` でリトライが有効になる
- [ ] 設定ファイルからリトライポリシーが読み込まれる
- [ ] ノードごとにカスタム設定が可能
- [ ] `Retry`クラスで直接設定を指定できる
- [ ] リトライ試行がログ出力される
- [ ] リトライ回数が表示される
- [ ] 最大リトライ後に例外が伝播する
- [ ] テストカバレッジ90%以上

---

## 次のIssue

- #12: エラー表示の改善
