fastmcp/tests/tasks/client/test_poll_interval.py
Jeremiah Lowin 74e01d5e08
Add SEP-2663 client half: transparent call_tool, ResultClaim, Task handle
A FastMCP client now transparently completes tasked tools/call: the tasks
ClientExtension advertises the capability and claims the CreateTaskResult, and
the resolver drives the tasks/get poll loop to completion, answering in-task
input through the client's elicitation handler and returning the tool's real
result. call_tool is transparent, call_tool_mcp exposes the raw result, and
call_tool_task yields a Task handle. The client half moves to fastmcp-tasks;
the [tasks] client extension auto-wires into Client (ProxyClient opts out).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 15:21:10 -04:00

61 lines
2.3 KiB
Python

"""Fallback poll cadence for client-side task waiting (SEP-2663).
The modern protocol has no task status notifications, so the client polls. The
backoff ramps from a fast floor, doubling up to a ceiling: the server-advertised
``pollIntervalMs`` when present (a statement about server load), else the client
``poll_interval`` setting. A quick task resolves in ~20ms; a long one settles to
the advertised cadence.
"""
from __future__ import annotations
import pytest
from fastmcp_tasks.client import MIN_POLL_INTERVAL, _next_poll_delay, _poll_ceiling
from fastmcp_tasks.settings import TasksClientSettings, client_settings
from pydantic import ValidationError
@pytest.mark.parametrize("value", [0, -0.5, -1])
def test_non_positive_poll_interval_setting_is_rejected(value: float):
with pytest.raises(ValidationError):
TasksClientSettings(poll_interval=value)
def test_positive_poll_interval_setting_is_accepted():
settings = TasksClientSettings(poll_interval=0.25)
assert settings.poll_interval == 0.25
@pytest.mark.parametrize("poll_interval_ms", [2000, 30_000])
def test_advertised_interval_caps_the_ramp(poll_interval_ms: int):
"""An advertised interval is the ceiling the ramp tops out at."""
assert _poll_ceiling(poll_interval_ms) == poll_interval_ms / 1000
def test_large_advertised_interval_is_honored():
day_ms = 24 * 60 * 60 * 1000
assert _poll_ceiling(day_ms) == 24 * 60 * 60
@pytest.mark.parametrize("poll_interval_ms", [None, 0, -1, -5000])
def test_absent_or_hostile_interval_falls_back_to_setting(poll_interval_ms):
"""An absent, zero, or negative server value cannot spin the client: use the setting."""
assert _poll_ceiling(poll_interval_ms) == client_settings.poll_interval
def test_ramp_doubles_from_floor_up_to_advertised_ceiling():
"""Even with an advertised interval, the poll ramps fast then caps at it."""
ceiling_ms = 500 # 0.5s ceiling
delays = []
backoff = MIN_POLL_INTERVAL
for _ in range(7):
delay, backoff = _next_poll_delay(ceiling_ms, backoff)
delays.append(delay)
assert delays == [0.02, 0.04, 0.08, 0.16, 0.32, 0.5, 0.5]
def test_first_delay_is_the_floor():
delay, backoff = _next_poll_delay(30_000, MIN_POLL_INTERVAL)
assert delay == MIN_POLL_INTERVAL
assert backoff == MIN_POLL_INTERVAL * 2