diff --git a/CHANGELOG.md b/CHANGELOG.md index bd71ff1..0a32479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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 + +- Preserve the API Call ID on SDK errors raised while waiting for a call result, + so callers can resume querying the same call after a connection failure, API + error, or timeout. The create-and-wait example accepts `--call-id` to resume. + ## [1.0.1] - 2026-09-23 ### Fixed diff --git a/README.md b/README.md index 84b2d16..3ba170d 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ Run the create-and-wait example from a local checkout: uv run python examples/create_and_wait.py ``` +If polling fails after creation, the example prints a command to resume the +existing Call. Keep the same API key and base URL, and pass that Call's ID: + +```bash +uv run python examples/create_and_wait.py --call-id call_123 +``` + +This mode only reads the existing Call; it does not create another. Calls +polling errors (`CalleAPIError`, `CalleConnectionError`, and `CalleTimeoutError`) +expose `error.call_id`, which is the Call's API `id`, not its Billing `call_id`. +This recovery support is available in this checkout and is not in the published +`1.0.1` package. If creation fails before an ID is received, `error.call_id` is +`None`; that does not prove no Call was created. Retry with the original request +and idempotency key rather than starting a replacement Call. + Run a published Goal with an explicit Goal, phone, variables, and durable idempotency key: diff --git a/examples/create_and_wait.py b/examples/create_and_wait.py index 8ffaa82..7c46d6d 100644 --- a/examples/create_and_wait.py +++ b/examples/create_and_wait.py @@ -1,32 +1,54 @@ +import argparse import json import os +import shlex +import sys -from calle import CalleClient +from calle import CalleAPIError, CalleClient, CalleConnectionError, CalleTimeoutError def main() -> None: - client = CalleClient( + parser = argparse.ArgumentParser() + parser.add_argument("--call-id", help="Resume waiting for an existing Call without creating another.") + args = parser.parse_args() + if args.call_id is not None and not args.call_id.strip(): + parser.error("--call-id must not be empty") + + with CalleClient( api_key=os.environ.get("CALLE_API_KEY", "calle_dev_example"), base_url=os.environ.get("CALLE_BASE_URL", "https://api.heycall-e.com"), - ) - - call = client.calls.create_and_wait( - task="Call the recipient and ask whether they can attend Friday lunch in San Francisco.", - phone=os.environ.get("CALLE_EXAMPLE_PHONE", "+14155550100"), - region="US", locale="en-US", - result_schema={ - "type": "object", - "additionalProperties": False, - "required": ["can_attend"], - "properties": { - "can_attend": {"type": "string", "enum": ["yes", "no", "unknown"]}, - }, - }, - metadata={"workflow_run_id": "example_local"}, - idempotency_key="example_local_friday_lunch", - interval_seconds=2.0, - timeout_seconds=600.0, - ) + ) as client: + try: + if args.call_id is not None: + call = client.calls.wait_for_result(args.call_id) + else: + call = client.calls.create_and_wait( + task="Call the recipient and ask whether they can attend Friday lunch in San Francisco.", + phone=os.environ.get("CALLE_EXAMPLE_PHONE", "+14155550100"), + region="US", locale="en-US", + result_schema={ + "type": "object", + "additionalProperties": False, + "required": ["can_attend"], + "properties": { + "can_attend": {"type": "string", "enum": ["yes", "no", "unknown"]}, + }, + }, + metadata={"workflow_run_id": "example_local"}, + idempotency_key="example_local_friday_lunch", + interval_seconds=2.0, + timeout_seconds=600.0, + ) + except (CalleAPIError, CalleConnectionError, CalleTimeoutError) as error: + if error.call_id is not None: + print( + "Resume this Call with: uv run python examples/create_and_wait.py " + f"--call-id {shlex.quote(error.call_id)}", + file=sys.stderr, + ) + else: + print("No Call ID received. Retry only with the same request and idempotency key.", file=sys.stderr) + raise print(json.dumps(call, indent=2, ensure_ascii=False)) diff --git a/src/calle/calls.py b/src/calle/calls.py index f855cb1..254cbd8 100644 --- a/src/calle/calls.py +++ b/src/calle/calls.py @@ -4,7 +4,7 @@ import httpx -from calle.errors import CalleConnectionError, CalleTimeoutError, api_error_from_response +from calle.errors import CalleAPIError, CalleConnectionError, CalleTimeoutError, api_error_from_response JsonObject = dict[str, Any] @@ -54,13 +54,17 @@ def wait_for_result( interval_seconds: float = 2.0, timeout_seconds: float = 600.0, ) -> JsonObject: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() <= deadline: - call = self.get(call_id) - if call["result_status"] != "pending": - return call - time.sleep(interval_seconds) - raise CalleTimeoutError(f"Timed out waiting for CALL-E call {call_id}.") + try: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() <= deadline: + call = self.get(call_id) + if call["result_status"] != "pending": + return call + time.sleep(interval_seconds) + raise CalleTimeoutError(f"Timed out waiting for CALL-E call {call_id}.") + except (CalleAPIError, CalleConnectionError, CalleTimeoutError) as error: + error.call_id = call_id + raise def create_and_wait(self, **kwargs: Any) -> JsonObject: interval_seconds = float(kwargs.pop("interval_seconds", 2.0)) diff --git a/src/calle/errors.py b/src/calle/errors.py index 14bfb1f..a8d8d6e 100644 --- a/src/calle/errors.py +++ b/src/calle/errors.py @@ -2,6 +2,8 @@ class CalleAPIError(Exception): + call_id: str | None = None + def __init__( self, *, @@ -25,11 +27,11 @@ class CalleRateLimitError(CalleAPIError): class CalleTimeoutError(Exception): - pass + call_id: str | None = None class CalleConnectionError(Exception): - pass + call_id: str | None = None class CalleWebhookSignatureError(Exception): diff --git a/tests/test_call_example.py b/tests/test_call_example.py new file mode 100644 index 0000000..64806ac --- /dev/null +++ b/tests/test_call_example.py @@ -0,0 +1,74 @@ +import json +import runpy +from collections.abc import Callable +from pathlib import Path +from typing import cast + +import httpx +import pytest +import respx + +from calle import CalleConnectionError + + +example = runpy.run_path(str(Path(__file__).parent.parent / "examples" / "create_and_wait.py")) +main = cast(Callable[[], None], example["main"]) + + +@respx.mock +def test_call_example_resumes_without_creating_another_call(monkeypatch, capsys) -> None: + monkeypatch.setenv("CALLE_BASE_URL", "https://api.heycall-e.com") + monkeypatch.setattr("sys.argv", ["create_and_wait.py"]) + created = {"id": "call_example", "call_id": "billing_example", "result_status": "pending"} + completed = {**created, "result_status": "available", "result": {"can_attend": "yes"}} + create = respx.post("https://api.heycall-e.com/v2/calls").respond(202, json=created) + read = respx.get("https://api.heycall-e.com/v2/calls/call_example").mock( + side_effect=[httpx.ConnectError("Offline polling failure."), httpx.Response(200, json=completed)] + ) + + with pytest.raises(CalleConnectionError) as exc_info: + main() + + assert exc_info.value.call_id == "call_example" + output = capsys.readouterr() + assert "--call-id call_example" in output.err + assert output.out == "" + monkeypatch.setattr("sys.argv", ["create_and_wait.py", "--call-id", exc_info.value.call_id]) + main() + + assert json.loads(capsys.readouterr().out) == completed + assert create.call_count == 1 + assert read.call_count == 2 + assert [request.method for request, _ in respx.calls] == ["POST", "GET", "GET"] + + +@respx.mock +def test_call_example_does_not_invent_an_id_after_create_failure(monkeypatch, capsys) -> None: + monkeypatch.setenv("CALLE_BASE_URL", "https://api.heycall-e.com") + monkeypatch.setattr("sys.argv", ["create_and_wait.py"]) + create = respx.post("https://api.heycall-e.com/v2/calls").mock( + side_effect=httpx.ConnectError("Offline creation failure.") + ) + + with pytest.raises(CalleConnectionError) as exc_info: + main() + + assert exc_info.value.call_id is None + output = capsys.readouterr() + assert "same request and idempotency key" in output.err + assert "--call-id" not in output.err + assert output.out == "" + assert create.call_count == 1 + assert len(respx.calls) == 1 + + +@pytest.mark.parametrize("call_id", ["", " "]) +@respx.mock +def test_call_example_rejects_empty_resume_id(monkeypatch, call_id) -> None: + monkeypatch.setattr("sys.argv", ["create_and_wait.py", "--call-id", call_id]) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 2 + assert len(respx.calls) == 0 diff --git a/tests/test_calls.py b/tests/test_calls.py index 3c22e48..953c278 100644 --- a/tests/test_calls.py +++ b/tests/test_calls.py @@ -5,7 +5,13 @@ import respx from calle import CalleClient -from calle.errors import CalleAPIError, CalleTimeoutError +from calle.errors import ( + CalleAPIError, + CalleAuthenticationError, + CalleConnectionError, + CalleRateLimitError, + CalleTimeoutError, +) COMPLETED_CALL = { @@ -181,6 +187,111 @@ def test_wait_for_result_raises_timeout() -> None: client.calls.wait_for_result("call_123", interval_seconds=0.001, timeout_seconds=0.002) +@pytest.mark.parametrize("create_first", [True, False]) +@pytest.mark.parametrize("failure, error_type", [ + (httpx.ConnectError("Connection lost."), CalleConnectionError), + (httpx.ReadTimeout("Read timed out."), CalleTimeoutError), + (502, CalleAPIError), + (401, CalleAuthenticationError), + (429, CalleRateLimitError), +]) +@respx.mock +def test_wait_errors_preserve_api_call_id_and_allow_recovery(create_first, failure, error_type) -> None: + create = respx.post("https://api.heycall-e.com/v2/calls").mock( + return_value=httpx.Response(202, json=COMPLETED_CALL) + ) + error_body = {"error": {"code": "test_error", "message": "Query failed.", "details": {"retry": "later"}}} + query_failure = httpx.Response(failure, json=error_body) if isinstance(failure, int) else failure + query = respx.get("https://api.heycall-e.com/v2/calls/call_123").mock( + side_effect=[query_failure, httpx.Response(200, json=COMPLETED_CALL)] + ) + with CalleClient(api_key="test") as client: + with pytest.raises(error_type) as exc_info: + if create_first: + client.calls.create_and_wait( + task="Call.", phone="+14155550100", idempotency_key="wf_123", + result_schema={"type": "object", "additionalProperties": False, "properties": {}}, + ) + else: + client.calls.wait_for_result("call_123") + error = exc_info.value + assert type(error) is error_type + assert error.call_id == COMPLETED_CALL["id"] + assert error.call_id != COMPLETED_CALL["call_id"] + if isinstance(failure, int): + assert error.status_code == failure + assert error.code == "test_error" + assert error.details == {"retry": "later"} + assert str(error) == "Query failed." + else: + assert error.__cause__ is failure + assert client.calls.wait_for_result(error.call_id) == COMPLETED_CALL + assert create.call_count == int(create_first) + assert query.call_count == 2 + + +@pytest.mark.parametrize("create_first", [True, False]) +@respx.mock +def test_wait_deadline_preserves_api_call_id(create_first, monkeypatch) -> None: + ticks = iter([0.0, 0.0, 2.0]) + monkeypatch.setattr("calle.calls.time.monotonic", lambda: next(ticks)) + monkeypatch.setattr("calle.calls.time.sleep", lambda _: None) + create = respx.post("https://api.heycall-e.com/v2/calls").mock( + return_value=httpx.Response(202, json=COMPLETED_CALL) + ) + query = respx.get("https://api.heycall-e.com/v2/calls/call_123").mock( + return_value=httpx.Response(200, json={**COMPLETED_CALL, "result_status": "pending", "result": None}) + ) + with CalleClient(api_key="test") as client, pytest.raises(CalleTimeoutError) as exc_info: + if create_first: + client.calls.create_and_wait( + task="Call.", phone="+14155550100", idempotency_key="wf_123", + result_schema={"type": "object", "additionalProperties": False, "properties": {}}, + timeout_seconds=1, + ) + else: + client.calls.wait_for_result("call_123", timeout_seconds=1) + assert exc_info.value.call_id == "call_123" + assert str(exc_info.value) == "Timed out waiting for CALL-E call call_123." + assert create.call_count == int(create_first) + assert query.call_count == 1 + + +@pytest.mark.parametrize("failure, error_type", [ + (httpx.ConnectError("Connection lost."), CalleConnectionError), + (httpx.ReadTimeout("Read timed out."), CalleTimeoutError), + (502, CalleAPIError), +]) +@respx.mock +def test_create_failure_does_not_claim_a_call_id(failure, error_type) -> None: + response = httpx.Response(failure, json={"error": {"code": "test_error", "message": "Create failed."}}) if isinstance(failure, int) else failure + create = respx.post("https://api.heycall-e.com/v2/calls").mock(side_effect=[response]) + with CalleClient(api_key="test") as client, pytest.raises(error_type) as exc_info: + client.calls.create_and_wait( + task="Call.", phone="+14155550100", idempotency_key="wf_123", + result_schema={"type": "object", "additionalProperties": False, "properties": {}}, + ) + assert exc_info.value.call_id is None + assert create.call_count == 1 + assert len(respx.calls) == 1 + + +@respx.mock +def test_create_and_wait_returns_result() -> None: + create = respx.post("https://api.heycall-e.com/v2/calls").mock( + return_value=httpx.Response(202, json=COMPLETED_CALL) + ) + query = respx.get("https://api.heycall-e.com/v2/calls/call_123").mock( + return_value=httpx.Response(200, json=COMPLETED_CALL) + ) + with CalleClient(api_key="test") as client: + assert client.calls.create_and_wait( + task="Call.", phone="+14155550100", idempotency_key="wf_123", + result_schema={"type": "object", "additionalProperties": False, "properties": {}}, + ) == COMPLETED_CALL + assert create.call_count == query.call_count == 1 + + def test_generated_call_round_trips_goal_compatible_result() -> None: from calle.generated.models.agentic_call import AgenticCall generated = AgenticCall.from_dict(COMPLETED_CALL)