# Task Recon: Implement httpx async client with auth header injection

**Task ID:** 7.1
**Status:** pending
**Generated:** 2026-01-28T23:45:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Create the base GraphClient class with httpx.AsyncClient setup, authentication header injection via AuthService, and core request infrastructure including `_build_url`, `_get_headers`, and `_request` methods. This subtask establishes the foundation for all Graph API communication.

### 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:** Establishes the core client infrastructure that subsequent subtasks (7.2 retry logic, 7.3 pagination, 7.4 error mapping) will build upon. This is the foundational layer - all other GraphClient functionality depends on the basic request/response infrastructure implemented here.

### Dependencies
- Depends on: Task 2 (Constants and Exception Hierarchy), Task 6 (AuthService with MSAL)

### Acceptance Criteria
- GraphClient class accepts AuthService in constructor
- `_build_url` constructs full URL from GRAPH_API_BASE + endpoint
- `_get_headers` returns Authorization header with Bearer token from AuthService
- `_request` makes async HTTP calls with proper headers
- Public `get` and `post` methods work for basic requests
- Implements async context manager protocol (`__aenter__`, `__aexit__`)
- All tests pass with respx mocking

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 2 | Constants and Exception Hierarchy | `GRAPH_API_BASE`, `DEFAULT_TIMEOUT`, `MAX_RETRIES` constants defined. Exception hierarchy with exit codes established. |
| 6 | AuthService with MSAL | `get_token()` method returns access token string. AuthService requires ConfigManager and CacheManager. Token caching and refresh logic already implemented. |

### Reusable Patterns from Dependencies

- **Class attribute pattern**: See `exit_code: int = ExitCode.X` in `exceptions.py:57` - use class attributes for constants
- **Keyword-only args**: See `*, remediation: str | None = None` in `exceptions.py:26` - use for optional parameters
- **Manager injection**: See `AuthService.__init__` in `auth_service.py:53-63` - store injected dependencies as instance attributes
- **Path expansion**: See `Path(cert_path).expanduser()` in `auth_service.py:80` - handle `~` in paths

### Warnings and Gotchas

- **MSAL validation at init**: The MSAL `ConfidentialClientApplication` makes a network call at instantiation - unit tests must mock it
- **Type hints required**: Mypy strict mode is enabled - all functions need complete type hints
- **httpx async**: Use `httpx.AsyncClient` not sync client - all methods must be async
- **Token refresh timing**: `get_token()` handles refresh automatically, but caller may need to call `refresh_token()` explicitly on 401

### Open Items Inherited

- [ ] Exception subclasses (TokenExpiredError, UserNotFoundError, etc.) to be added when specific features need them
- [ ] CLI layer will handle formatting with Rich library - exceptions stay simple

---

## Architecture Context

### Relevant Design Decisions

- **Layered Architecture**: GraphClient lives in `infrastructure/` layer, depends on AuthService from `services/` layer
- **httpx for async HTTP**: Project uses httpx (not aiohttp) for modern async HTTP support
- **Beta API endpoint**: All Teams Phone operations use `https://graph.microsoft.com/beta`
- **Repository pattern**: Services abstract Graph API + CSV cache access

### Module Structure

```
src/teams_phone/
  infrastructure/
    __init__.py          # Exports GraphClient (will need update)
    graph_client.py      # NEW FILE - this subtask
    config_manager.py    # Existing - configuration TOML handling
    cache_manager.py     # Existing - token/CSV caching
  services/
    auth_service.py      # Provides get_token() for GraphClient
  constants.py           # GRAPH_API_BASE, DEFAULT_TIMEOUT, MAX_RETRIES
  exceptions.py          # TeamsPhoneError hierarchy
```

### Conventions to Follow

- Module docstrings at top of file explaining purpose (see `auth_service.py:1-5`)
- Class docstrings with Attributes section (see `AuthService:42-51`)
- Method docstrings with Args, Returns, Raises sections (Google style)
- Use `| None` not `Optional[...]` for type hints
- Import exceptions from `teams_phone.exceptions` not individually

---

## Related Code References

### Primary References (Must Read)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/constants.py` | 9-19, 38-42 | GRAPH_API_BASE, DEFAULT_TIMEOUT, MAX_RETRIES constants |
| `src/teams_phone/services/auth_service.py` | 388-417 | `get_token()` method - returns access token string |
| `src/teams_phone/infrastructure/cache_manager.py` | 14-52 | Class structure pattern for infrastructure components |
| `tests/unit/test_auth_service.py` | 26-75 | Test fixture patterns and mocking approach |

### Secondary References (Helpful Context)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/infrastructure/config_manager.py` | 19-38 | `__init__` pattern with default path handling |
| `src/teams_phone/exceptions.py` | 10-44 | Exception base class with remediation |
| `.taskmaster/docs/architecture/5-components.md` | 109-128 | GraphClient specification |
| `pyproject.toml` | 8 | httpx version: `httpx>=0.25.0` |

### Code Patterns to Follow

```python
# Pattern 1: Infrastructure class __init__ (cache_manager.py:25-42)
class CacheManager:
    def __init__(
        self,
        cache_dir: str | None = None,
        token_dir: str | None = None,
    ) -> None:
        cache_str = cache_dir if cache_dir is not None else DEFAULT_CACHE_PATH
        self._cache_dir: Path = Path(cache_str).expanduser()
```

```python
# Pattern 2: Service injection and storage (auth_service.py:53-63)
class AuthService:
    def __init__(
        self, config_manager: ConfigManager, cache_manager: CacheManager
    ) -> None:
        self.config_manager = config_manager
        self.cache_manager = cache_manager
```

```python
# Pattern 3: Module docstring (auth_service.py:1-5)
"""Authentication service for Microsoft Entra ID authentication.

This module provides the AuthService class for handling certificate-based
and client-secret authentication with Microsoft Graph API.
"""
```

---

## Dependencies

### Internal Modules

- `teams_phone.services.auth_service.AuthService` - provides `get_token()` for auth headers
- `teams_phone.constants` - GRAPH_API_BASE, DEFAULT_TIMEOUT constants
- `teams_phone.exceptions` - exception classes for error handling (used in 7.4 but import now)

### External Packages

- `httpx>=0.25.0` - async HTTP client (already in pyproject.toml)
- No new packages needed

### New Dependencies Needed

None required - httpx is already a project dependency.

---

## Testing Guidance

### Test File Location

`tests/unit/test_graph_client.py` (new file to create)

### Test Patterns in Use

```python
# Pattern: Test class organization (tests/unit/test_auth_service.py:78-101)
class TestAuthServiceInit:
    """Tests for AuthService initialization."""

    @pytest.mark.unit
    def test_accepts_dependencies(self, tmp_path: Path) -> None:
        """AuthService accepts ConfigManager and CacheManager."""
        config_manager = ConfigManager(config_path=str(tmp_path / "config.toml"))
        cache_manager = CacheManager(token_dir=str(tmp_path / "tokens"))

        service = AuthService(config_manager, cache_manager)

        assert service.config_manager is config_manager
```

```python
# Pattern: Mocking external calls (tests/unit/test_auth_service.py:285-302)
with patch(
    "teams_phone.services.auth_service.ConfidentialClientApplication"
) as mock_app:
    mock_instance = MagicMock()
    mock_app.return_value = mock_instance

    app = service._create_msal_app(profile)

    assert app is mock_instance
    mock_app.assert_called_once()
```

### Fixtures Available

- `tmp_path` (pytest built-in) - temporary directory for test isolation
- `monkeypatch` (pytest built-in) - environment variable manipulation

### Test Utilities

- `tests/conftest.py` - pytest marker configuration
- `respx` - HTTP mocking library for httpx (use this, not `responses`)
- `pytest.mark.unit` - marker for fast unit tests

### Test Strategy from Task

Unit tests with respx mocking:
1. Test URL building with/without leading slash
2. Header injection with mocked AuthService token
3. Successful GET/POST requests returning JSON
4. Async context manager enter/exit

---

## Risks and Blockers

### Technical Risks

- [ ] **Async context manager complexity**: httpx.AsyncClient requires proper lifecycle management. Ensure `__aenter__` creates client and `__aexit__` closes it.
- [ ] **Type hints for httpx**: httpx has good type stubs but verify mypy passes with strict mode.

### Dependency Risks

- [ ] **AuthService must be tested first**: Ensure Task 6 is fully complete before starting (it is - per task notes)
- [ ] **respx version compatibility**: Ensure respx works with httpx version in project

### Open Questions

- [ ] Should GraphClient support sync operations or be async-only? (Recommend async-only based on architecture docs)
- [ ] Should `_request` return raw `httpx.Response` or parsed JSON dict? (Task details say `_request` returns `httpx.Response`, public methods return `dict`)

---

## Implementation Recommendations

### Suggested Approach

1. Create `src/teams_phone/infrastructure/graph_client.py` with imports and docstring
2. Implement `GraphClient.__init__` storing AuthService and creating timeout config
3. Implement `_build_url(endpoint: str) -> str` with leading slash handling
4. Implement `_get_headers() -> dict[str, str]` calling `auth_service.get_token()`
5. Implement async `_request(method: str, url: str, **kwargs) -> httpx.Response`
6. Implement async `get(endpoint: str, params: dict | None = None) -> dict`
7. Implement async `post(endpoint: str, body: dict) -> dict`
8. Implement `__aenter__` and `__aexit__` for context manager
9. Update `infrastructure/__init__.py` to export GraphClient
10. Create tests in `tests/unit/test_graph_client.py`

### Files to Create/Modify

| Action | File | Reason |
|--------|------|--------|
| Create | `src/teams_phone/infrastructure/graph_client.py` | Main implementation |
| Create | `tests/unit/test_graph_client.py` | Unit tests |
| Modify | `src/teams_phone/infrastructure/__init__.py` | Export GraphClient |

### Quick Wins

- Start with `_build_url` - simplest method, easy to test, validates setup
- Use `httpx.AsyncClient(timeout=DEFAULT_TIMEOUT)` for timeout configuration

---

## Cheat Sheet

### Key Imports

```python
import httpx

from teams_phone.constants import DEFAULT_TIMEOUT, GRAPH_API_BASE
from teams_phone.services.auth_service import AuthService
```

### Key Functions to Use

- `auth_service.get_token()` - returns access token string for Bearer header
- `httpx.AsyncClient(timeout=...)` - creates async HTTP client with timeout

### Common Patterns

- **URL building**: `f"{GRAPH_API_BASE}/{endpoint.lstrip('/')}"` handles leading slash
- **Auth header**: `{"Authorization": f"Bearer {token}", "Content-Type": "application/json"}`
- **Async context manager**:
  ```python
  async def __aenter__(self) -> "GraphClient":
      self._client = httpx.AsyncClient(timeout=self._timeout)
      return self

  async def __aexit__(self, *args) -> None:
      await self._client.aclose()
  ```

### httpx Request Pattern

```python
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
    headers = self._get_headers()
    return await self._client.request(method, url, headers=headers, **kwargs)
```

### Test Pattern with respx

```python
import respx
from httpx import Response

@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_request(mock_auth_service):
    with respx.mock:
        respx.get("https://graph.microsoft.com/beta/users").mock(
            return_value=Response(200, json={"value": []})
        )

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

        assert result == {"value": []}
```

---

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