Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
64 changes: 43 additions & 21 deletions examples/create_and_wait.py
Original file line number Diff line number Diff line change
@@ -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))

Expand Down
20 changes: 12 additions & 8 deletions src/calle/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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))
Expand Down
6 changes: 4 additions & 2 deletions src/calle/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@


class CalleAPIError(Exception):
call_id: str | None = None

def __init__(
self,
*,
Expand All @@ -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):
Expand Down
74 changes: 74 additions & 0 deletions tests/test_call_example.py
Original file line number Diff line number Diff line change
@@ -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
113 changes: 112 additions & 1 deletion tests/test_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
Loading