From c41f672179ff95fb5080212580fc8b34ee73164f Mon Sep 17 00:00:00 2001 From: cnpierrepapi Date: Sun, 27 Sep 2026 17:46:57 +0100 Subject: [PATCH] fix: validate calls polling arguments like goals does calls.wait_for_result passed interval_seconds straight to time.sleep, so 0 polled GET /v2/calls/{id} with no delay until the timeout, and nan or a negative value failed inside time.sleep without naming the argument. create_and_wait also sent the create POST before anything was checked. Reuse goals' _validate_polling_seconds in both, and in create_and_wait check before the POST so a bad argument never places a call. Refs #30 --- CHANGELOG.md | 9 ++++++++ src/calle/calls.py | 5 ++++ tests/test_calls.py | 56 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd71ff1..e882fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Validate `interval_seconds` and `timeout_seconds` in `calls.wait_for_result` and + `calls.create_and_wait` the same way the Goal Run helpers do. Zero, negative, + NaN and infinite values now raise a `ValueError` naming the argument, and + `create_and_wait` raises before the create request is sent. + ## [1.0.1] - 2026-09-23 ### Fixed diff --git a/src/calle/calls.py b/src/calle/calls.py index f855cb1..06bff60 100644 --- a/src/calle/calls.py +++ b/src/calle/calls.py @@ -5,6 +5,7 @@ import httpx from calle.errors import CalleConnectionError, CalleTimeoutError, api_error_from_response +from calle.goals import _validate_polling_seconds JsonObject = dict[str, Any] @@ -54,6 +55,8 @@ def wait_for_result( interval_seconds: float = 2.0, timeout_seconds: float = 600.0, ) -> JsonObject: + _validate_polling_seconds(interval_seconds, "interval_seconds") + _validate_polling_seconds(timeout_seconds, "timeout_seconds") deadline = time.monotonic() + timeout_seconds while time.monotonic() <= deadline: call = self.get(call_id) @@ -65,6 +68,8 @@ def wait_for_result( def create_and_wait(self, **kwargs: Any) -> JsonObject: interval_seconds = float(kwargs.pop("interval_seconds", 2.0)) timeout_seconds = float(kwargs.pop("timeout_seconds", 600.0)) + _validate_polling_seconds(interval_seconds, "interval_seconds") + _validate_polling_seconds(timeout_seconds, "timeout_seconds") call = self.create(**kwargs) return self.wait_for_result( str(call["id"]), diff --git a/tests/test_calls.py b/tests/test_calls.py index 3c22e48..3a305ba 100644 --- a/tests/test_calls.py +++ b/tests/test_calls.py @@ -215,3 +215,59 @@ def handle_request(request: httpx.Request) -> httpx.Response: encoded_id = "%2E%2E%2Fgoals%3Fadmin%3D1%23fragment%252F" assert requests[0].url.raw_path == f"/v2/calls/{encoded_id}".encode() assert requests[1].url.raw_path == f"/v2/calls/{encoded_id}/events".encode() + + +INVALID_POLLING_SECONDS = [ + ("interval_seconds", 0.0), + ("interval_seconds", -1.0), + ("interval_seconds", float("nan")), + ("interval_seconds", float("inf")), + ("timeout_seconds", 0.0), + ("timeout_seconds", -1.0), + ("timeout_seconds", float("nan")), + ("timeout_seconds", float("inf")), +] + + +@pytest.mark.parametrize(("parameter", "value"), INVALID_POLLING_SECONDS) +@respx.mock +def test_wait_for_result_rejects_invalid_polling_seconds_without_request( + parameter: str, + value: float, +) -> None: + route = respx.get("https://api.heycall-e.com/v2/calls/call_123").mock( + return_value=httpx.Response(200, json=COMPLETED_CALL) + ) + client = CalleClient(api_key="key_test") + options = {"interval_seconds": 1.0, "timeout_seconds": 1.0} + options[parameter] = value + + with pytest.raises(ValueError, match=f"{parameter} must be a finite positive number"): + client.calls.wait_for_result("call_123", **options) + + assert route.call_count == 0 + + +@pytest.mark.parametrize(("parameter", "value"), INVALID_POLLING_SECONDS) +@respx.mock +def test_create_and_wait_rejects_invalid_polling_seconds_before_create( + parameter: str, + value: float, +) -> None: + route = respx.post("https://api.heycall-e.com/v2/calls").mock( + return_value=httpx.Response(201, json=COMPLETED_CALL) + ) + client = CalleClient(api_key="key_test") + options = {"interval_seconds": 1.0, "timeout_seconds": 1.0} + options[parameter] = value + + with pytest.raises(ValueError, match=f"{parameter} must be a finite positive number"): + client.calls.create_and_wait( + task="Call.", + phone="+14155550100", + idempotency_key="call:test:v1", + result_schema={"type": "object", "properties": {}, "additionalProperties": False}, + **options, + ) + + assert route.call_count == 0