# Issue #18: カスタムエラー型階層

**Phase:** 1c
**優先度:** 中
**依存関係:** #03, #12
**見積もり:** 0.5日

---

## 概要

Railway Framework専用のエラー型階層を定義する。
エラーの分類、リトライ可否の判定、ユーザーフレンドリーなメッセージ生成を可能にする。

---

## TDD実装手順

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

```python
# tests/unit/core/test_errors.py
"""Tests for custom error types."""
import pytest


class TestRailwayError:
    """Test base RailwayError."""

    def test_railway_error_is_exception(self):
        """Should be an Exception subclass."""
        from railway.core.errors import RailwayError

        assert issubclass(RailwayError, Exception)

    def test_railway_error_has_message(self):
        """Should store message."""
        from railway.core.errors import RailwayError

        error = RailwayError("Test error")
        assert str(error) == "Test error"

    def test_railway_error_has_code(self):
        """Should have error code."""
        from railway.core.errors import RailwayError

        error = RailwayError("Test", code="E001")
        assert error.code == "E001"

    def test_railway_error_has_hint(self):
        """Should have hint message."""
        from railway.core.errors import RailwayError

        error = RailwayError("Test", hint="Try doing X")
        assert error.hint == "Try doing X"


class TestConfigurationError:
    """Test ConfigurationError."""

    def test_config_error_is_railway_error(self):
        """Should be RailwayError subclass."""
        from railway.core.errors import ConfigurationError, RailwayError

        assert issubclass(ConfigurationError, RailwayError)

    def test_config_error_not_retryable(self):
        """Configuration errors should not be retryable."""
        from railway.core.errors import ConfigurationError

        error = ConfigurationError("Missing config")
        assert error.retryable is False

    def test_config_error_has_default_hint(self):
        """Should have default hint for config errors."""
        from railway.core.errors import ConfigurationError

        error = ConfigurationError("Missing API key")
        assert error.hint is not None
        assert "config" in error.hint.lower() or "設定" in error.hint


class TestNodeError:
    """Test NodeError."""

    def test_node_error_stores_node_name(self):
        """Should store node name."""
        from railway.core.errors import NodeError

        error = NodeError("Failed", node_name="fetch_data")
        assert error.node_name == "fetch_data"

    def test_node_error_stores_original_error(self):
        """Should store original exception."""
        from railway.core.errors import NodeError

        original = ValueError("Original")
        error = NodeError("Wrapped", original_error=original)
        assert error.original_error is original

    def test_node_error_retryable_by_default(self):
        """Node errors should be retryable by default."""
        from railway.core.errors import NodeError

        error = NodeError("Temporary failure")
        assert error.retryable is True


class TestPipelineError:
    """Test PipelineError."""

    def test_pipeline_error_stores_step_info(self):
        """Should store step information."""
        from railway.core.errors import PipelineError

        error = PipelineError(
            "Pipeline failed",
            step_number=3,
            step_name="process_data",
            total_steps=5
        )
        assert error.step_number == 3
        assert error.step_name == "process_data"
        assert error.total_steps == 5

    def test_pipeline_error_shows_remaining_steps(self):
        """Should show remaining steps count."""
        from railway.core.errors import PipelineError

        error = PipelineError(
            "Failed",
            step_number=2,
            step_name="step2",
            total_steps=5
        )
        assert error.remaining_steps == 3


class TestNetworkError:
    """Test NetworkError."""

    def test_network_error_is_retryable(self):
        """Network errors should be retryable."""
        from railway.core.errors import NetworkError

        error = NetworkError("Connection failed")
        assert error.retryable is True

    def test_network_error_has_network_hint(self):
        """Should have network-related hint."""
        from railway.core.errors import NetworkError

        error = NetworkError("Timeout")
        assert "ネットワーク" in error.hint or "network" in error.hint.lower()


class TestValidationError:
    """Test ValidationError."""

    def test_validation_error_not_retryable(self):
        """Validation errors should not be retryable."""
        from railway.core.errors import ValidationError

        error = ValidationError("Invalid input")
        assert error.retryable is False

    def test_validation_error_stores_field(self):
        """Should store field name."""
        from railway.core.errors import ValidationError

        error = ValidationError("Invalid", field="email")
        assert error.field == "email"


class TestErrorHierarchy:
    """Test error hierarchy relationships."""

    def test_all_errors_are_railway_errors(self):
        """All custom errors should be RailwayError subclass."""
        from railway.core.errors import (
            RailwayError,
            ConfigurationError,
            NodeError,
            PipelineError,
            NetworkError,
            ValidationError,
        )

        for error_class in [
            ConfigurationError,
            NodeError,
            PipelineError,
            NetworkError,
            ValidationError,
        ]:
            assert issubclass(error_class, RailwayError)

    def test_errors_catchable_as_railway_error(self):
        """Should be catchable as RailwayError."""
        from railway.core.errors import (
            RailwayError,
            ConfigurationError,
            NodeError,
        )

        with pytest.raises(RailwayError):
            raise ConfigurationError("Test")

        with pytest.raises(RailwayError):
            raise NodeError("Test")


class TestErrorFormatting:
    """Test error message formatting."""

    def test_error_full_message(self):
        """Should format full error message with all details."""
        from railway.core.errors import NodeError

        error = NodeError(
            "Processing failed",
            node_name="process_data",
            code="E100",
            hint="Check input data format"
        )

        full_msg = error.full_message()
        assert "E100" in full_msg
        assert "process_data" in full_msg
        assert "Processing failed" in full_msg
        assert "Check input data format" in full_msg

    def test_error_to_dict(self):
        """Should convert to dictionary."""
        from railway.core.errors import PipelineError

        error = PipelineError(
            "Step failed",
            step_number=2,
            step_name="step2",
            total_steps=5,
            code="P001"
        )

        d = error.to_dict()
        assert d["message"] == "Step failed"
        assert d["code"] == "P001"
        assert d["step_number"] == 2
        assert d["step_name"] == "step2"
```

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

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

```python
# railway/core/errors.py
"""Custom error types for Railway Framework."""
from typing import Any, Dict, Optional


class RailwayError(Exception):
    """Base exception for all Railway Framework errors."""

    def __init__(
        self,
        message: str,
        *,
        code: Optional[str] = None,
        hint: Optional[str] = None,
        retryable: bool = False,
    ):
        super().__init__(message)
        self.message = message
        self.code = code
        self.hint = hint
        self.retryable = retryable

    def full_message(self) -> str:
        """Get full formatted error message."""
        parts = []

        if self.code:
            parts.append(f"[{self.code}]")

        parts.append(self.message)

        if self.hint:
            parts.append(f"\nヒント: {self.hint}")

        return " ".join(parts)

    def to_dict(self) -> Dict[str, Any]:
        """Convert error to dictionary."""
        return {
            "type": self.__class__.__name__,
            "message": self.message,
            "code": self.code,
            "hint": self.hint,
            "retryable": self.retryable,
        }


class ConfigurationError(RailwayError):
    """Error related to configuration issues."""

    def __init__(
        self,
        message: str,
        *,
        code: Optional[str] = None,
        hint: Optional[str] = None,
        config_key: Optional[str] = None,
    ):
        if hint is None:
            hint = "設定ファイル（config/*.yaml）または環境変数を確認してください。"

        super().__init__(message, code=code, hint=hint, retryable=False)
        self.config_key = config_key

    def to_dict(self) -> Dict[str, Any]:
        d = super().to_dict()
        d["config_key"] = self.config_key
        return d


class NodeError(RailwayError):
    """Error that occurred in a node."""

    def __init__(
        self,
        message: str,
        *,
        code: Optional[str] = None,
        hint: Optional[str] = None,
        retryable: bool = True,
        node_name: Optional[str] = None,
        original_error: Optional[Exception] = None,
    ):
        super().__init__(message, code=code, hint=hint, retryable=retryable)
        self.node_name = node_name
        self.original_error = original_error

    def full_message(self) -> str:
        """Get full formatted error message with node info."""
        parts = []

        if self.code:
            parts.append(f"[{self.code}]")

        if self.node_name:
            parts.append(f"[{self.node_name}]")

        parts.append(self.message)

        if self.hint:
            parts.append(f"\nヒント: {self.hint}")

        return " ".join(parts)

    def to_dict(self) -> Dict[str, Any]:
        d = super().to_dict()
        d["node_name"] = self.node_name
        if self.original_error:
            d["original_error"] = {
                "type": type(self.original_error).__name__,
                "message": str(self.original_error),
            }
        return d


class PipelineError(RailwayError):
    """Error that occurred in a pipeline."""

    def __init__(
        self,
        message: str,
        *,
        code: Optional[str] = None,
        hint: Optional[str] = None,
        step_number: Optional[int] = None,
        step_name: Optional[str] = None,
        total_steps: Optional[int] = None,
        original_error: Optional[Exception] = None,
    ):
        super().__init__(message, code=code, hint=hint, retryable=False)
        self.step_number = step_number
        self.step_name = step_name
        self.total_steps = total_steps
        self.original_error = original_error

    @property
    def remaining_steps(self) -> Optional[int]:
        """Get number of remaining steps after failure."""
        if self.step_number is not None and self.total_steps is not None:
            return self.total_steps - self.step_number
        return None

    def full_message(self) -> str:
        """Get full formatted error message with pipeline info."""
        parts = []

        if self.code:
            parts.append(f"[{self.code}]")

        if self.step_name and self.step_number:
            parts.append(f"Step {self.step_number} ({self.step_name}):")

        parts.append(self.message)

        if self.remaining_steps is not None and self.remaining_steps > 0:
            parts.append(f"(残り {self.remaining_steps} ステップはスキップされました)")

        if self.hint:
            parts.append(f"\nヒント: {self.hint}")

        return " ".join(parts)

    def to_dict(self) -> Dict[str, Any]:
        d = super().to_dict()
        d["step_number"] = self.step_number
        d["step_name"] = self.step_name
        d["total_steps"] = self.total_steps
        d["remaining_steps"] = self.remaining_steps
        return d


class NetworkError(RailwayError):
    """Error related to network operations."""

    def __init__(
        self,
        message: str,
        *,
        code: Optional[str] = None,
        hint: Optional[str] = None,
        url: Optional[str] = None,
        status_code: Optional[int] = None,
    ):
        if hint is None:
            hint = "ネットワーク接続を確認してください。APIエンドポイントが正しいか確認してください。"

        super().__init__(message, code=code, hint=hint, retryable=True)
        self.url = url
        self.status_code = status_code

    def to_dict(self) -> Dict[str, Any]:
        d = super().to_dict()
        d["url"] = self.url
        d["status_code"] = self.status_code
        return d


class ValidationError(RailwayError):
    """Error related to data validation."""

    def __init__(
        self,
        message: str,
        *,
        code: Optional[str] = None,
        hint: Optional[str] = None,
        field: Optional[str] = None,
        value: Any = None,
    ):
        if hint is None:
            hint = "入力データの形式を確認してください。"

        super().__init__(message, code=code, hint=hint, retryable=False)
        self.field = field
        self.value = value

    def to_dict(self) -> Dict[str, Any]:
        d = super().to_dict()
        d["field"] = self.field
        d["value"] = repr(self.value) if self.value is not None else None
        return d


class TimeoutError(RailwayError):
    """Error when operation times out."""

    def __init__(
        self,
        message: str,
        *,
        code: Optional[str] = None,
        hint: Optional[str] = None,
        timeout_seconds: Optional[float] = None,
    ):
        if hint is None:
            hint = "タイムアウト値を増やすか、処理を分割してください。"

        super().__init__(message, code=code, hint=hint, retryable=True)
        self.timeout_seconds = timeout_seconds

    def to_dict(self) -> Dict[str, Any]:
        d = super().to_dict()
        d["timeout_seconds"] = self.timeout_seconds
        return d
```

```python
# railway/core/__init__.py を更新
"""Railway Core exports."""
from railway.core.decorators import node, entry_point, Retry
from railway.core.pipeline import pipeline, async_pipeline
from railway.core.errors import (
    RailwayError,
    ConfigurationError,
    NodeError,
    PipelineError,
    NetworkError,
    ValidationError,
    TimeoutError,
)

__all__ = [
    "node",
    "entry_point",
    "Retry",
    "pipeline",
    "async_pipeline",
    "RailwayError",
    "ConfigurationError",
    "NodeError",
    "PipelineError",
    "NetworkError",
    "ValidationError",
    "TimeoutError",
]
```

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

---

## 完了条件

- [ ] `RailwayError` ベースクラスが定義されている
- [ ] `ConfigurationError` が設定エラー用に使える
- [ ] `NodeError` がノードエラー用に使える
- [ ] `PipelineError` がパイプラインエラー用に使える
- [ ] `NetworkError` がネットワークエラー用に使える
- [ ] `ValidationError` がバリデーションエラー用に使える
- [ ] すべてのエラーが `RailwayError` を継承
- [ ] `retryable` 属性でリトライ可否を判定できる
- [ ] `full_message()` でフォーマット済みメッセージを取得できる
- [ ] `to_dict()` でエラー情報を辞書化できる
- [ ] テストカバレッジ90%以上

---

## 次のIssue

- #19: 遅延初期化（_SettingsProxy）
