# Task Recon: Implement error mapping to exception hierarchy

**Task ID:** 7.4
**Status:** pending
**Generated:** 2026-01-28T12:00:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create the `_handle_error` method in GraphClient that maps HTTP status codes and Graph API error responses to the appropriate exception hierarchy (AuthenticationError, AuthorizationError, NotFoundError, ValidationError, RateLimitError, ContractError).

### Parent Task Context

**Parent Task:** Implement GraphClient with Retry Logic (ID: 7)
**Parent Goal:** Create the HTTP client for Microsoft Graph API with retry/backoff, throttling handling, and Pydantic validation.
**This Subtask's Role:** Provides comprehensive error mapping to convert HTTP responses into meaningful, typed exceptions with remediation guidance. This completes the GraphClient by replacing generic `raise_for_status()` calls with semantic exception handling.

### Dependencies
- Depends on: 7.1 (httpx async client with auth header injection - DONE)

### Acceptance Criteria
From task details:
1. `_handle_error(self, response: httpx.Response) -> None` method that parses responses and raises appropriate exceptions
2. Status code to exception mapping: 400->ValidationError, 401->AuthenticationError, 403->AuthorizationError, 404->NotFoundError, 409->ValidationError, 429->RateLimitError, 5xx->TeamsPhoneError
3. Extract `error.code` and `error.message` from OData error format
4. Include remediation hints (401: re-authenticate, 403: check permissions, 429: wait/reduce rate)
5. Handle non-JSON error responses gracefully
6. Integrate with get/post methods to call `_handle_error` on non-success responses

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 7.1 | Implement httpx async client | Token acquisition (`get_token()`) is sync; `raise_for_status()` is currently used in get/post methods |
| 7.2 | Implement retry logic | 401 is handled separately from tenacity retry; 429/5xx retries exhaust before error mapping |
| 7.3 | Implement pagination | `response.raise_for_status()` is called after each request; need to replace with `_handle_error` |

### Reusable Patterns from Dependencies
- **Exception raising with remediation**: See `src/teams_phone/infrastructure/graph_client.py:139-146` during 401 handling
- **Error extraction pattern**: GraphErrorResponse model exists in `src/teams_phone/models/api_responses.py:32-44`

### Warnings and Gotchas
- **raise_for_status() must be replaced**: Both `get()` and `post()` methods currently call `response.raise_for_status()` which raises `httpx.HTTPStatusError`. Task 7.4 must replace these with `_handle_error` calls.
- **Non-retryable status codes**: 400, 403, 404, 409 are NOT retried by tenacity - they pass through `_request` unchanged and need error mapping.
- **401 handled separately**: 401 is already handled in `_request` with token refresh logic, but after exhaustion it raises `AuthenticationError`.
- **429/5xx after exhaustion**: Retry exhaustion already raises `RateLimitError` or `TeamsPhoneError` in `_raise_for_exhausted_retries` callback.

### Open Items Inherited
- [ ] Error mapping for non-retryable status codes (this task addresses this)

---

## Architecture Context

### Relevant Design Decisions
- **Exception hierarchy**: All exceptions inherit from `TeamsPhoneError` with specific exit codes (2-8)
- **Remediation pattern**: Exceptions support optional `remediation` kwarg for user guidance
- **OData error format**: Graph API returns `{"error": {"code": "...", "message": "..."}}` structure
- **Pydantic strict mode**: Models use `extra="forbid"` to catch API contract changes early

### Module Structure
```
src/teams_phone/
├── infrastructure/
│   └── graph_client.py    # _handle_error goes here
├── models/
│   └── api_responses.py   # GraphErrorDetail, GraphErrorResponse models exist
└── exceptions.py          # Exception hierarchy to map to
```

### Conventions to Follow
- Use keyword-only arguments for optional parameters (`*, remediation: str | None = None`)
- Include docstrings with Args/Returns/Raises sections
- Use `dict[str, Any]` for JSON response typing
- Test with `@pytest.mark.unit` and `@pytest.mark.asyncio` decorators

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/infrastructure/graph_client.py` | 286-325 | `get()` and `post()` methods that need to call `_handle_error` |
| `src/teams_phone/infrastructure/graph_client.py` | 123-146 | Existing `_raise_for_exhausted_retries` pattern for raising exceptions |
| `src/teams_phone/exceptions.py` | 1-138 | Full exception hierarchy with exit codes and remediation support |
| `src/teams_phone/models/api_responses.py` | 11-44 | GraphErrorDetail and GraphErrorResponse Pydantic models |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `tests/unit/test_graph_client.py` | 237-248 | Current test expecting `httpx.HTTPStatusError` (will need updating) |
| `tests/unit/test_models.py` | 1745-1906 | Tests for GraphErrorDetail/GraphErrorResponse parsing |
| `tests/unit/test_exceptions.py` | 1-225 | Exception testing patterns |

### Code Patterns to Follow

```python
# Exception raising with remediation (graph_client.py:139-146)
raise AuthenticationError(
    "Authentication failed after token refresh",
    remediation="Run `teams-phone auth login` to re-authenticate",
)
```

```python
# OData error parsing (models/api_responses.py:11-29)
class GraphErrorDetail(BaseModel):
    model_config = ConfigDict(extra="forbid", populate_by_name=True)
    code: str
    message: str
    target: str | None = None
    details: list["GraphErrorDetail"] | None = None
```

```python
# Testing async methods with respx (test_graph_client.py:237-248)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_raises_on_error(self, mock_auth_service: MagicMock) -> None:
    with respx.mock:
        respx.get(f"{GRAPH_API_BASE}/users").mock(
            return_value=httpx.Response(
                404, json={"error": {"message": "Not found"}}
            )
        )
        async with GraphClient(mock_auth_service) as client:
            with pytest.raises(httpx.HTTPStatusError):
                await client.get("/users")
```

---

## Dependencies

### Internal Modules
- `teams_phone.exceptions` - AuthenticationError, AuthorizationError, NotFoundError, ValidationError, RateLimitError, ContractError, TeamsPhoneError
- `teams_phone.models.api_responses` - GraphErrorResponse, GraphErrorDetail (for optional Pydantic parsing)

### External Packages
- `httpx` - Response type for `_handle_error` parameter
- `pydantic` - Optional for parsing error body with validation

### New Dependencies Needed
- None required - all necessary packages already available

---

## Testing Guidance

### Test File Location
`tests/unit/test_graph_client.py`

### Test Patterns in Use
```python
# Async test with respx mocking (test_graph_client.py:237-248)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_example(self, mock_auth_service: MagicMock) -> None:
    with respx.mock:
        respx.get(f"{GRAPH_API_BASE}/endpoint").mock(
            return_value=httpx.Response(STATUS_CODE, json={...})
        )
        async with GraphClient(mock_auth_service) as client:
            with pytest.raises(ExpectedException) as exc_info:
                await client.get("/endpoint")
            assert "expected message" in str(exc_info.value)
```

### Fixtures Available
- `mock_auth_service` - `MagicMock(spec=AuthService)` returning test token

### Test Utilities
- `respx` for HTTP mocking
- `pytest.raises` for exception assertions
- `GRAPH_API_BASE` constant from `teams_phone.constants`

### Tests to Write (from testStrategy)
1. Each status code mapping to correct exception type (400, 403, 404, 409, 5xx)
2. Error message extraction from OData error format
3. Remediation text inclusion in exceptions
4. Non-JSON error body handling (use status text as message)
5. Unknown status code fallback

---

## Risks and Blockers

### Technical Risks
- [ ] **Breaking change**: Replacing `raise_for_status()` with `_handle_error()` will change the exception types raised by `get()` and `post()`. Existing tests expect `httpx.HTTPStatusError` - these need updating.
- [ ] **401/429/5xx overlap**: These codes are already handled by retry logic. `_handle_error` should NOT be called for these after retry exhaustion (already converted to exceptions). Need to clarify when `_handle_error` is called vs when retry callbacks raise.

### Dependency Risks
- [ ] None - all dependencies are complete (7.1 done, retry logic in 7.2 done)

### Open Questions
- [ ] Should `_handle_error` be called BEFORE retry logic (to prevent retrying on 400/403/404/409) or AFTER `_request` returns for non-retryable codes?
- [ ] Should the method use `GraphErrorResponse.model_validate()` for strict parsing or just dict access for flexibility?

---

## Implementation Recommendations

### Suggested Approach
1. Create `_handle_error(self, response: httpx.Response) -> None` method with full docstring
2. Parse response body as JSON if Content-Type is application/json, otherwise use status text
3. Extract `error.code` and `error.message` from OData format if available
4. Map status codes to exceptions with appropriate messages and remediation hints
5. Replace `response.raise_for_status()` calls in `get()` and `post()` with:
   ```python
   if not response.is_success:
       self._handle_error(response)
   ```
6. Update tests to expect specific exception types instead of `httpx.HTTPStatusError`

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/infrastructure/graph_client.py` | Add `_handle_error` method, update `get`/`post` |
| Modify | `tests/unit/test_graph_client.py` | Update existing tests, add new error mapping tests |

### Quick Wins
- Start with the status code mapping logic (simple if/elif chain)
- JSON parsing with try/except for non-JSON responses
- Test one status code at a time

---

## Cheat Sheet

### Key Imports
```python
from teams_phone.exceptions import (
    AuthenticationError,
    AuthorizationError,
    ContractError,
    NotFoundError,
    RateLimitError,
    TeamsPhoneError,
    ValidationError,
)
```

### Key Functions to Use
- `response.is_success` - Check if status code is 2xx
- `response.json()` - Parse JSON body (may raise)
- `response.status_code` - Get HTTP status code
- `response.text` - Get response body as text (fallback)

### Status Code Mapping Reference
| Status | Exception | Remediation |
|--------|-----------|-------------|
| 400 | ValidationError | Include error.message details |
| 401 | AuthenticationError | "Run `teams-phone auth login` to re-authenticate" |
| 403 | AuthorizationError | "Verify application has required Graph API permissions" |
| 404 | NotFoundError | Include resource context from URL |
| 409 | ValidationError | Resource state conflict |
| 429 | RateLimitError | "Wait and retry, or reduce request rate" |
| 5xx | TeamsPhoneError | "Graph API unavailable" message |

### Common Patterns
- **OData error extraction**: `data.get("error", {}).get("message", response.reason_phrase)`
- **JSON parsing with fallback**: `try: data = response.json() except: data = {}`

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
