# Task Recon: Implement poll_operation method with timeout and exponential backoff

**Task ID:** 20.1
**Status:** pending
**Generated:** 2026-01-29
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Add the `poll_operation` async method to `NumberService` that polls the Graph API operation status endpoint until completion or timeout, using 2-3 second intervals with jitter.

### Parent Task Context

**Parent Task:** Implement Async Operation Polling (ID: 20)
**Parent Goal:** Add async operation polling to NumberService for assign/unassign operations that return 202 Accepted. This enables the CLI to track the status of long-running phone number assignment/unassignment operations.
**This Subtask's Role:** Implement the core polling mechanism (`poll_operation` method) that will be used by future assign/unassign methods to wait for operation completion. The method handles timeout, terminal state detection, and optional progress callbacks.

### Dependencies
- Depends on: Task 19 (NumberService Core Operations) - **COMPLETED**

### Acceptance Criteria
1. Method accepts `operation_id` (str), `timeout` (int, default 60s), and optional `on_progress` callback
2. Polls `/admin/teams/telephoneNumberManagement/operations/{operation_id}` endpoint
3. Uses 2-3 second polling intervals with jitter (`asyncio.sleep(2 + random.uniform(0, 1))`)
4. Detects terminal states: `succeeded`, `failed`, `skipped`
5. Raises `TimeoutError` when elapsed time exceeds timeout
6. Invokes `on_progress` callback on each poll iteration if provided
7. Returns `AsyncOperation` model on completion

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 19 | Implement NumberService Core Operations | All 3 subtasks completed. Service pattern established with GraphClient injection via constructor. |
| 19.1 | list_numbers | OData filter format: `fieldName eq 'value'` with lowercase field names and single quotes. City filter is client-side only. |
| 19.2 | get_number | Phone number normalization uses `format_for_update()`. Async iterator early return pattern used. |
| 19.3 | search_numbers | Client-side filtering pattern after fetching all results. Early exit optimization for max_results. |

### Reusable Patterns from Dependencies
- **Service initialization**: `self.graph_client = graph_client` in `__init__` - see `number_service.py:29-35`
- **TYPE_CHECKING import for GraphClient**: Avoids circular imports - see `number_service.py:15-16`
- **Model validation**: `AsyncOperation.model_validate(result)` - see similar pattern at `number_service.py:97`

### Warnings and Gotchas
- **Graph API endpoint structure**: Operations endpoint uses `/{operationId}` directly, NOT OData-style `('{operationId}')` in the path
- **Terminal states only**: Do NOT return early on `notStarted` or `running` - these are non-terminal states

### Open Items Inherited
- None from Task 19 - all subtasks are complete

---

## Architecture Context

### Relevant Design Decisions
- Services use repository pattern - abstract Graph API access
- Graph assign/unassign operations return 202 and **require polling** per CLAUDE.md
- Pydantic strict mode (`extra="forbid"`) for fail-fast on API contract changes

### Module Structure
```
src/teams_phone/services/number_service.py  <- Add poll_operation here
src/teams_phone/models/number.py            <- AsyncOperation, OperationStatus already defined
src/teams_phone/infrastructure/graph_client.py <- GraphClient.get() for polling
```

### Conventions to Follow
- Use `from __future__ import annotations` at top of file
- Use `TYPE_CHECKING` block for GraphClient import to avoid circular dependencies
- Use keyword-only arguments with `*` separator for optional parameters
- All async methods should have comprehensive docstrings with Args/Returns/Raises

---

## Related Code References

### Primary References (Must Read)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/number_service.py` | 1-186 | Current NumberService implementation - add poll_operation here |
| `src/teams_phone/models/number.py` | 76-129 | OperationStatus enum and AsyncOperation model definitions |
| `src/teams_phone/infrastructure/graph_client.py` | 367-395 | GraphClient.get() method for making GET requests |
| `.taskmaster/docs/graph-api-contracts/2-phone-number-operations-verified.md` | 226-276 | API contract for polling operations endpoint |

### Secondary References (Helpful Context)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/user_service.py` | 1-250 | Reference service implementation patterns |
| `tests/unit/test_number_service.py` | 1-854 | Test patterns for NumberService |
| `src/teams_phone/constants.py` | 38-42 | DEFAULT_TIMEOUT and MAX_RETRIES constants |

### Code Patterns to Follow

```python
# Service method pattern from number_service.py:37-103
async def list_numbers(
    self,
    *,
    status: AssignmentStatus | None = None,
    # ... keyword-only args
) -> list[NumberAssignment]:
    """Method docstring with Args/Returns/Raises."""
    # Implementation
```

```python
# Model validation pattern from number_service.py:97
number = NumberAssignment.model_validate(item)
```

```python
# TYPE_CHECKING import pattern from number_service.py:15-16
if TYPE_CHECKING:
    from teams_phone.infrastructure.graph_client import GraphClient
```

---

## Dependencies

### Internal Modules
- `teams_phone.models.AsyncOperation` - Response model for operation status
- `teams_phone.models.OperationStatus` - Enum with terminal states (succeeded, failed, skipped)
- `teams_phone.infrastructure.graph_client.GraphClient` - For making GET requests (TYPE_CHECKING import)

### External Packages
- `asyncio` (stdlib) - For `asyncio.sleep()` with jitter
- `random` (stdlib) - For `random.uniform(0, 1)` jitter
- `time` (stdlib) - For `time.time()` timeout tracking
- `typing.Callable` (stdlib) - For `on_progress` callback type hint

### New Dependencies Needed
- None required - all dependencies are stdlib or already in use

---

## Testing Guidance

### Test File Location
`tests/unit/test_number_service.py` - Add new test class `TestNumberServicePollOperation`

### Test Patterns in Use

```python
# Mock GraphClient fixture from test_number_service.py:54-57
@pytest.fixture
def mock_graph_client() -> MagicMock:
    """Create a mock GraphClient for testing."""
    return MagicMock()
```

```python
# Async test pattern from test_number_service.py:74-92
@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_numbers_returns_empty_list(
    self, mock_graph_client: MagicMock
) -> None:
    """Docstring."""
    # Mock setup
    mock_graph_client.get = AsyncMock(return_value={...})

    service = NumberService(mock_graph_client)
    result = await service.method(...)

    assert result == expected
```

### Fixtures Available
- `mock_graph_client` - MagicMock for GraphClient

### Test Utilities
- `make_number_dict()` helper in test file for creating number data
- Need to create similar `make_operation_dict()` helper for AsyncOperation data

### Required Test Cases (from task spec)
1. Poll succeeds after N iterations (mock API returning 'running' then 'succeeded')
2. Poll timeout after elapsed time (mock never returns terminal state)
3. Progress callback invoked on each iteration
4. All terminal states (succeeded, failed, skipped) properly detected and returned

---

## Risks and Blockers

### Technical Risks
- [ ] **Time mocking complexity**: Testing timeout behavior requires mocking `time.time()` - use `unittest.mock.patch` or pytest-freezegun
- [ ] **Async sleep mocking**: Need to mock `asyncio.sleep` to avoid actual delays in tests - use `AsyncMock` or set side_effect

### Dependency Risks
- None - Task 19 (NumberService Core) is complete and working

### Open Questions
- [ ] Should `poll_operation` be a public method or prefixed with `_` as private? Task details show it as public without underscore.
- [ ] What exception type to raise on operation `failed` status? Task spec only mentions TimeoutError for timeout - may return failed AsyncOperation to let caller decide.

---

## Implementation Recommendations

### Suggested Approach
1. Add required imports at top of `number_service.py`: `import asyncio`, `import random`, `import time`, `from typing import Callable`
2. Add `AsyncOperation` and `OperationStatus` to model imports
3. Implement `poll_operation` method following the pseudo-code from task details
4. Create test helper `make_operation_dict()` for test data
5. Write unit tests covering all terminal states and timeout scenarios

### Files to Create/Modify

| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/services/number_service.py` | Add poll_operation method |
| Modify | `tests/unit/test_number_service.py` | Add TestNumberServicePollOperation test class |

### Quick Wins
- Start with simple happy path test (immediate success)
- Then add timeout test with mocked time.time()
- Finally add multi-iteration test with state transitions

---

## Cheat Sheet

### Key Imports
```python
# Add to existing imports in number_service.py
import asyncio
import random
import time
from typing import TYPE_CHECKING, Any, Callable

from teams_phone.models import AsyncOperation, OperationStatus
```

### Key Functions to Use
- `time.time()` - Get current timestamp for timeout tracking
- `asyncio.sleep(seconds)` - Async sleep with jitter
- `random.uniform(0, 1)` - Generate jitter between 0 and 1 second
- `self.graph_client.get(endpoint)` - Make GET request to poll operation status
- `AsyncOperation.model_validate(data)` - Parse API response into model

### API Endpoint Pattern
```
GET /admin/teams/telephoneNumberManagement/operations/{operationId}
```

### Terminal States
```python
TERMINAL_STATES = {OperationStatus.SUCCEEDED, OperationStatus.FAILED, OperationStatus.SKIPPED}
# Or use string comparison: {"succeeded", "failed", "skipped"}
```

### Method Signature (from task details)
```python
async def poll_operation(
    self,
    operation_id: str,
    timeout: int = 60,
    on_progress: Callable[[AsyncOperation], None] | None = None,
) -> AsyncOperation:
```

### Poll Loop Pattern
```python
start = time.time()
while time.time() - start < timeout:
    result = await self.graph_client.get(f"/admin/teams/telephoneNumberManagement/operations/{operation_id}")
    operation = AsyncOperation.model_validate(result)
    if on_progress:
        on_progress(operation)
    if operation.status in (OperationStatus.SUCCEEDED, OperationStatus.FAILED, OperationStatus.SKIPPED):
        return operation
    await asyncio.sleep(2 + random.uniform(0, 1))
raise TimeoutError(f"Operation {operation_id} did not complete within {timeout}s")
```

---

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