# Task ID: 7

**Title:** Implement GraphClient with Retry Logic

**Status:** pending

**Dependencies:** 2, 6

**Priority:** high

**Description:** Create the HTTP client for Microsoft Graph API with retry/backoff, throttling handling, and Pydantic validation.

**Details:**

Create `src/teams_phone/infrastructure/graph_client.py`:
```python
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential

class GraphClient:
    def __init__(self, auth_service: AuthService, timeout: int = DEFAULT_TIMEOUT): ...
    
    async def get(self, endpoint: str, params: dict | None = None) -> dict: ...
    async def post(self, endpoint: str, body: dict) -> dict: ...
    async def get_paginated(self, endpoint: str, params: dict | None = None, max_items: int | None = None) -> AsyncIterator[dict]: ...
    
    def _build_url(self, endpoint: str) -> str: ...
    def _get_headers(self) -> dict: ...
    async def _request(self, method: str, url: str, **kwargs) -> httpx.Response: ...
    def _handle_error(self, response: httpx.Response) -> None: ...
```

Retry on 429 (use Retry-After header), 500, 502, 503, 504.
Exponential backoff: 1s, 2s, 4s.
Refresh token on 401 and retry once.
Raise appropriate exceptions from hierarchy.

**Test Strategy:**

Unit tests with respx mocking: test successful requests, retry on 429/5xx, token refresh on 401, proper error mapping, pagination handling.

---

## Subtask 7.1: Implement httpx async client with auth header injection

---

## Implementation Notes

### Session: 2026-01-28 23:50 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Created `src/teams_phone/infrastructure/graph_client.py` with full GraphClient implementation
- Implemented `__init__` with AuthService injection and configurable timeout (defaults to DEFAULT_TIMEOUT)
- Implemented `_build_url(endpoint)` - constructs full URL handling leading slash variations
- Implemented `_get_headers()` - returns Authorization Bearer token and Content-Type headers
- Implemented async `_request(method, url, **kwargs)` - core HTTP request with header injection
- Implemented async `get(endpoint, params)` and `post(endpoint, body)` public methods
- Implemented async context manager protocol (`__aenter__`, `__aexit__`) for proper httpx.AsyncClient lifecycle
- Updated `src/teams_phone/infrastructure/__init__.py` to export GraphClient
- Created comprehensive unit tests in `tests/unit/test_graph_client.py` (20 tests covering all methods)

#### Key Decisions Made
- **Async-only design**: GraphClient is async-only as specified in architecture docs. Sync operations are not supported.
- **RuntimeError for context violation**: Raises RuntimeError with helpful message if `_request` is called outside async context manager
- **Type hints for JSON responses**: Used `result: dict[str, Any] = response.json()` pattern to satisfy mypy strict mode
- **respx for HTTP mocking**: Used respx library (already in project) instead of responses for httpx compatibility

#### Patterns Discovered
- **httpx timeout config**: `httpx.AsyncClient(timeout=...)` uses the timeout value for all timeout types (connect, read, write, pool)
- **URL encoding**: Graph API query params with `$` are URL-encoded as `%24` by httpx - tests check for unencoded portion only

#### Open Items for Next Agent
- [ ] Subtask 7.2: Add retry logic with tenacity for 429/5xx errors
- [ ] Subtask 7.3: Add pagination support with `get_paginated()` method
- [ ] Subtask 7.4: Add error mapping to exception hierarchy

#### Gotchas and Warnings
- **Token acquisition is sync**: `auth_service.get_token()` is synchronous, called from `_get_headers()` which is also sync
- **raise_for_status()**: Both `get()` and `post()` call `response.raise_for_status()` - error mapping will be added in 7.4

---

## Subtask 7.2: Implement retry logic with tenacity for 429 and 5xx handling

---

## Implementation Notes

### Session: 2026-01-29 00:00 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `tenacity>=8.0.0` to `pyproject.toml` dependencies
- Implemented retry helper functions in `graph_client.py`:
  - `_is_retryable_status(response)` - checks for 429, 500, 502, 503, 504
  - `_get_retry_after(response)` - parses Retry-After header (integer seconds or HTTP-date format)
  - `_custom_wait(retry_state)` - uses Retry-After header or exponential backoff (1s, 2s, 4s)
  - `_log_retry(retry_state)` - logs retry attempts before sleeping
  - `_raise_for_exhausted_retries(retry_state)` - raises RateLimitError or TeamsPhoneError
- Split `_request` method into:
  - `_request()` - handles 401 token refresh with single retry
  - `_request_with_retry()` - wrapped with tenacity decorator for transient errors
- Added `RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})`
- Created 28 new unit tests covering:
  - Retry helper functions (14 tests)
  - Retry behavior on each status code (10 tests)
  - 401 token refresh flow (4 tests)

#### Key Decisions Made
- **Separate 401 handling**: 401 is NOT retryable via tenacity. It uses explicit single-retry with token refresh in `_request()`. This keeps auth flow clear and avoids complexity with tenacity's retry logic.
- **Custom wait callback**: Uses Retry-After header when available for 429 responses, otherwise falls back to exponential backoff.
- **HTTP-date parsing**: Used `email.utils.parsedate_to_datetime` for RFC 7231 compliant Retry-After parsing.
- **retry_error_callback**: Raises appropriate exception types after retry exhaustion (RateLimitError for 429, TeamsPhoneError for 5xx).

#### Patterns Discovered
- **tenacity retry_if_result**: Works with async functions, retries when predicate returns True for the result
- **Type annotation for timedelta.total_seconds()**: Returns Any type, needs explicit `delta: float` annotation for mypy strict mode
- **respx side_effect**: Use list of responses for testing retry sequences

#### Open Items for Next Agent
- [ ] Subtask 7.3: Add pagination support with `get_paginated()` method
- [ ] Subtask 7.4: Add error mapping to exception hierarchy

#### Gotchas and Warnings
- **Retry-After=0 for tests**: Use `headers={"Retry-After": "0"}` in tests to avoid actual delays during retry tests
- **Token refresh is sync**: `auth_service.refresh_token()` is synchronous, called from async `_request()` - this works but may block the event loop briefly
- **MAX_RETRIES from constants**: Using `MAX_RETRIES = 3` from `teams_phone.constants`, not hardcoded

---

## Subtask 7.3: Implement pagination handling with AsyncIterator

---

## Implementation Notes

### Session: 2026-01-28 14:15 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `AsyncIterator` import from `typing` module
- Added `ContractError` import from `teams_phone.exceptions`
- Implemented `get_paginated()` method in `GraphClient`:
  - Returns `AsyncIterator[dict[str, Any]]` yielding individual items
  - Follows `@odata.nextLink` for multi-page responses
  - Respects `max_items` limit, stopping mid-page if needed
  - Raises `ContractError` if `value` key missing from response
  - Handles empty `value` arrays gracefully (yields nothing)
  - `max_items=0` returns immediately without making requests
  - Uses `_request()` internally to get retry logic on each page
- Created 11 new unit tests in `TestGraphClientGetPaginated` class:
  - Single page response yields all items
  - Multi-page pagination follows nextLink
  - max_items limit stops mid-page
  - max_items prevents fetching unnecessary pages
  - Empty value array handling
  - Missing value key raises ContractError
  - max_items=0 returns immediately
  - Retry logic applies to paginated requests (429/5xx)
  - Retry on second page
  - Query params passed to initial request
  - Non-2xx responses raise HTTPStatusError

#### Key Decisions Made
- **AsyncIterator with yield**: Used `async def ... -> AsyncIterator[T]:` with `yield` statements for memory-efficient streaming
- **nextLink is full URL**: The `@odata.nextLink` returned by Graph API is a complete URL, not a relative endpoint. The implementation correctly passes this full URL directly to `_request()` without using `_build_url()`.
- **Params only on initial request**: Query parameters are only passed to the first request. Subsequent pages use the full nextLink URL which has parameters embedded.
- **ContractError for schema violations**: Missing `value` key indicates an API contract change and raises `ContractError` with remediation guidance.

#### Patterns Discovered
- **respx url__eq matching**: Must use `url__eq` for exact URL matching in tests with query params. Without it, respx uses prefix matching which causes tests with pagination to hang (both URLs match first route, creating infinite loop).
- **Async generator early return**: `if max_items == 0: return` is valid syntax in async generators for early exit without yielding anything.
- **Type annotation for empty dict**: Use `dict[str, list[dict[str, str]]]` for empty `{"value": []}` to satisfy mypy strict mode.

#### Open Items for Next Agent
- [ ] Subtask 7.4: Add error mapping to exception hierarchy (convert HTTP status codes to appropriate exceptions)

#### Gotchas and Warnings
- **respx prefix matching**: By default, `respx.get("https://...")` matches URLs by prefix. For tests with pagination, you MUST use `respx.get(url__eq="https://...")` for exact matching to prevent infinite loops.
- **nextLink URL handling**: Do NOT use `_build_url()` for nextLink URLs - they are already complete URLs from Graph API.
- **params vs embedded URL params**: Initial request uses `params` kwarg, but nextLink requests don't - the URL already contains query parameters.

---

## Subtask 7.4: Implement error mapping to exception hierarchy

---

## Implementation Notes

### Session: 2026-01-28 12:30 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Implemented `_handle_error(self, response: httpx.Response) -> None` method in `GraphClient`:
  - Parses OData error format (`{"error": {"code": "...", "message": "..."}}`) from JSON responses
  - Extracts error code and message for inclusion in exception messages
  - Falls back to response text or reason phrase for non-JSON responses
  - Maps status codes to exception hierarchy with appropriate remediation hints
- Replaced all `response.raise_for_status()` calls with `_handle_error()`:
  - Updated `get()` method
  - Updated `post()` method
  - Updated `get_paginated()` method
- Updated existing tests to expect specific exceptions instead of `httpx.HTTPStatusError`:
  - `test_get_raises_not_found_error` (was `test_get_raises_on_error`)
  - `test_post_raises_validation_error` (was `test_post_raises_on_error`)
  - `test_raises_not_found_on_404_response` (was `test_raises_on_non_2xx_response`)
- Added 13 new tests in `TestGraphClientHandleError` class covering:
  - Status code 400 → ValidationError
  - Status code 401 → AuthenticationError with remediation
  - Status code 403 → AuthorizationError with remediation
  - Status code 404 → NotFoundError
  - Status code 409 → ValidationError (conflict)
  - Status code 500 → TeamsPhoneError (after retry exhaustion)
  - OData error code and message extraction
  - Non-JSON error response handling
  - Empty error response handling
  - Malformed JSON error handling
  - Error response without code field
  - Unknown 4xx status codes → TeamsPhoneError
  - 502 status after retry exhaustion

#### Key Decisions Made
- **No Pydantic validation for errors**: Used dict access with `.get()` instead of `GraphErrorResponse.model_validate()` to handle malformed/partial error responses gracefully. Strict validation would fail on non-standard error formats.
- **Truncate long text responses**: Non-JSON error text is truncated to 200 chars to prevent excessively long exception messages.
- **401 handled at multiple layers**: 401 triggers token refresh retry in `_request()` first. If that fails, `_handle_error()` raises `AuthenticationError`. Both paths provide remediation guidance.
- **409 mapped to ValidationError**: Conflict errors (409) map to `ValidationError` as they typically indicate resource state conflicts (e.g., user already has a number assigned).
- **Unknown status codes**: Non-mapped 4xx codes and other unexpected statuses raise generic `TeamsPhoneError` with the status code in the message.

#### Patterns Discovered
- **response.is_success**: Cleaner than checking `response.status_code < 400`; returns True for 2xx status codes
- **response.reason_phrase**: Falls back to HTTP status text when error body is empty/unparseable
- **Error code in message**: Including both code and message (`{code}: {message}`) helps users understand Graph API errors

#### Open Items for Next Agent
- None - Task 7 (GraphClient with Retry Logic) is now complete with all subtasks implemented

#### Gotchas and Warnings
- **429/5xx still handled by tenacity**: The retry logic in `_request_with_retry()` handles 429 and 5xx status codes BEFORE they reach `_handle_error()`. If retry exhaustion occurs, `_raise_for_exhausted_retries` raises the exception, not `_handle_error()`.
- **401 has two exception paths**: Token refresh failure in `_request()` raises `AuthenticationError` directly. A 401 that reaches `_handle_error()` would also raise `AuthenticationError`, but this path is unlikely given the refresh retry.
- **Test 401 requires refresh failure**: To test 401 error mapping via `_handle_error()`, tests must make `auth_service.refresh_token()` throw an exception, otherwise the request retries with a new token.

---
