# Task Recon: Implement pagination handling with AsyncIterator

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

---

## Task Summary

Create the `get_paginated` method in GraphClient that handles Microsoft Graph API pagination using `@odata.nextLink`, yielding individual items as an `AsyncIterator[dict]` with optional `max_items` limit.

### 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. The GraphClient provides authenticated HTTP access to Graph API with automatic retry for transient failures.
**This Subtask's Role:** Add pagination support to enable efficient iteration over large result sets without loading all data into memory. This is critical for listing users, phone numbers, and other resources that may return thousands of items.

### Dependencies
- Depends on: 7.1 (httpx async client with auth header injection) - **COMPLETE**
- Related: 7.2 (retry logic with tenacity) - **COMPLETE** - retry logic will apply to each paginated request

### Acceptance Criteria
1. `get_paginated` method yields individual items from `value` array
2. Follows `@odata.nextLink` for multi-page responses
3. Respects `max_items` limit, stopping mid-page if needed
4. Raises `ContractError` if `value` key is missing from response
5. Handles empty `value` arrays gracefully (yields nothing, stops)
6. `max_items=0` returns immediately without making requests
7. Retry logic from subtask 7.2 applies to each paginated request

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 7.1 | Implement httpx async client with auth header injection | Async-only design; RuntimeError if used outside context manager; `dict[str, Any]` return type pattern for mypy strict |
| 7.2 | Implement retry logic with tenacity | 401 handled separately from transient retries; use `Retry-After: 0` in tests to avoid delays; `_request_with_retry` has tenacity decorator |

### Reusable Patterns from Dependencies
- **JSON response typing**: Use `result: dict[str, Any] = response.json()` pattern (see `graph_client.py:299`)
- **respx side_effect for sequences**: Use list of responses for testing multi-page scenarios (see `test_graph_client.py:420-424`)
- **Async test structure**: Use `@pytest.mark.asyncio` decorator with `async with GraphClient(...) as client:` pattern

### Warnings and Gotchas
- **Token acquisition is sync**: `auth_service.get_token()` is synchronous - works but may briefly block event loop
- **raise_for_status()**: Current `get()` and `post()` call this - pagination should handle errors before yielding
- **nextLink is a FULL URL**: Do not use `_build_url()` for nextLink - it's already a complete URL from Graph API

### Open Items Inherited
- [ ] Subtask 7.4 will add error mapping to exception hierarchy (current methods use `raise_for_status()`)

---

## Architecture Context

### Relevant Design Decisions
- **AsyncIterator pattern**: Architecture doc specifies `get_with_pagination(endpoint, params, max_items) -> AsyncIterator[dict]` (components doc line 117)
- **Retry on each request**: Each paginated request should benefit from retry logic (429, 5xx handling)
- **ContractError for schema violations**: Missing `value` key indicates API contract change

### Module Structure
```
src/teams_phone/infrastructure/
├── __init__.py          # Exports GraphClient
└── graph_client.py      # Add get_paginated method here
```

The `get_paginated` method will be used by service layer components (UserService, NumberService) to list resources.

### Conventions to Follow
- Use `typing.AsyncIterator` for return type annotation
- Follow existing docstring format with Args/Returns/Raises sections
- Keep `async with` context manager requirement - client must be initialized
- Use `_request` method internally to get retry logic benefits

---

## Related Code References

### Primary References (Must Read)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/infrastructure/graph_client.py` | 281-300 | Existing `get()` method - similar structure, shows how to use `_request` |
| `src/teams_phone/infrastructure/graph_client.py` | 195-250 | `_request` method with retry and 401 handling |
| `src/teams_phone/exceptions.py` | 124-138 | `ContractError` class definition |
| `tests/unit/test_graph_client.py` | 201-249 | Test patterns for `get()` method |

### Secondary References (Helpful Context)

| File | Lines | Purpose |
|------|-------|---------|
| `.taskmaster/docs/graph-api-contracts/1-user-operations-verified.md` | 12-13 | Pagination params `$top`, `$skip` |
| `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md` | 12-13 | Pagination with max 1000 items per page |
| `src/teams_phone/constants.py` | 1-97 | Constants including GRAPH_API_BASE |

### Code Patterns to Follow

```python
# Existing get() method pattern (graph_client.py:281-300)
async def get(
    self,
    endpoint: str,
    *,
    params: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Make an async GET request to a Graph API endpoint."""
    url = self._build_url(endpoint)
    response = await self._request("GET", url, params=params)
    response.raise_for_status()
    result: dict[str, Any] = response.json()
    return result
```

```python
# respx side_effect for multi-response tests (test_graph_client.py:420-424)
route = respx.get(f"{GRAPH_API_BASE}/users")
route.side_effect = [
    httpx.Response(429, headers={"Retry-After": "0"}),
    httpx.Response(200, json={"value": []}),
]
```

---

## Dependencies

### Internal Modules
- `teams_phone.exceptions.ContractError` - for missing `value` key
- `teams_phone.constants.GRAPH_API_BASE` - for URL building (initial request only)

### External Packages
- `typing.AsyncIterator` - return type annotation
- `httpx` - HTTP client (already configured via `_client`)

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

---

## Testing Guidance

### Test File Location
`tests/unit/test_graph_client.py` - add new test class `TestGraphClientGetPaginated`

### Test Patterns in Use

```python
# Async test with respx mock (test_graph_client.py:204-218)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_returns_json(self, mock_auth_service: MagicMock) -> None:
    """get method returns parsed JSON response."""
    expected_data = {"value": [{"id": "user1"}, {"id": "user2"}]}

    with respx.mock:
        respx.get(f"{GRAPH_API_BASE}/users").mock(
            return_value=httpx.Response(200, json=expected_data)
        )

        async with GraphClient(mock_auth_service) as client:
            result = await client.get("/users")

        assert result == expected_data
```

### Fixtures Available
- `mock_auth_service` - MagicMock of AuthService that returns test token (test_graph_client.py:22-26)

### Test Scenarios to Cover
1. **Single page response** - yields all items from `value` array
2. **Multi-page pagination** - follows `@odata.nextLink` for subsequent pages
3. **max_items limit mid-page** - stops yielding when limit reached
4. **max_items limit across pages** - stops before fetching unnecessary pages
5. **Empty value array** - yields nothing, stops gracefully
6. **Missing value key** - raises `ContractError`
7. **max_items=0** - returns immediately without making requests
8. **Retry logic applies** - 429/5xx on paginated request triggers retry

### Test Utilities
- `respx.mock` context manager for HTTP mocking
- `route.side_effect = [...]` for multi-response sequences
- `async for item in client.get_paginated(...)` to consume async iterator

---

## Risks and Blockers

### Technical Risks
- [ ] **AsyncIterator typing with mypy strict**: May need explicit type annotation. Mitigate by using `AsyncIterator[dict[str, Any]]` from `typing` module.
- [ ] **nextLink URL handling**: nextLink is a full URL, not a relative endpoint. Must NOT use `_build_url()` for follow-up requests.

### Dependency Risks
- None - all dependencies (7.1, 7.2) are complete

### Open Questions
- [ ] Should `get_paginated` call `response.raise_for_status()` or defer to future error mapping (7.4)? **Recommendation**: Call it for now, consistent with `get()` and `post()`. Error mapping in 7.4 will enhance this.

---

## Implementation Recommendations

### Suggested Approach
1. Add import for `AsyncIterator` from `typing` module at top of file
2. Add import for `ContractError` from `teams_phone.exceptions`
3. Implement `get_paginated` as async generator method using `async def` with `yield`
4. Handle `max_items=0` edge case first (early return)
5. Make initial request using `_request` method (gets retry logic)
6. Check for `value` key, raise `ContractError` if missing
7. Yield items from `value` array, tracking count against `max_items`
8. Check for `@odata.nextLink`, follow if present and limit not reached
9. For nextLink requests, use full URL directly (not `_build_url`)

### Files to Create/Modify

| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/infrastructure/graph_client.py` | Add `get_paginated` method |
| Modify | `tests/unit/test_graph_client.py` | Add test class for pagination |

### Quick Wins
- Start with single-page test case to validate basic async generator pattern
- Add `max_items=0` test early - simplest edge case

---

## Cheat Sheet

### Key Imports
```python
from typing import Any, AsyncIterator
from teams_phone.exceptions import ContractError
```

### Method Signature
```python
async def get_paginated(
    self,
    endpoint: str,
    *,
    params: dict[str, Any] | None = None,
    max_items: int | None = None,
) -> AsyncIterator[dict[str, Any]]:
```

### Key Patterns
- **Async generator**: `async def ... -> AsyncIterator[T]:` with `yield` statements
- **Early return for max_items=0**: `if max_items == 0: return` (valid in async generators)
- **nextLink extraction**: `next_link = data.get("@odata.nextLink")`
- **Full URL for nextLink**: `await self._request("GET", next_link)` (no `_build_url`)

### Graph API Pagination Response Format
```json
{
  "@odata.context": "...",
  "value": [
    {"id": "item1", ...},
    {"id": "item2", ...}
  ],
  "@odata.nextLink": "https://graph.microsoft.com/beta/...?$skip=100"
}
```

---

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