From bbc59c650105fab134f34a7c70822075f72de618 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Tue, 24 Mar 2026 16:42:58 -0600 Subject: [PATCH 1/2] Notifications and Escrows cleanup Coded by Codex with prompts from Kent Bull --- src/signify/app/escrowing.py | 6 +++- src/signify/app/notifying.py | 12 +++++--- tests/app/test_escrowing.py | 44 ++++++++++++++++++++++++++- tests/app/test_integration_helpers.py | 6 ++-- tests/app/test_notifying.py | 20 ++++++++++-- tests/integration/helpers.py | 2 +- 6 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/signify/app/escrowing.py b/src/signify/app/escrowing.py index e2f1429..e4c257a 100644 --- a/src/signify/app/escrowing.py +++ b/src/signify/app/escrowing.py @@ -10,7 +10,7 @@ def __init__(self, client: SignifyClient): """Create an escrow resource bound to one Signify client.""" self.client = client - def getEscrowReply(self, route=None): + def listReply(self, route=None): """Return escrowed reply records, optionally filtered by route.""" params = {} if route is not None: @@ -18,3 +18,7 @@ def getEscrowReply(self, route=None): res = self.client.get(f"/escrows/rpy", params=params) return res.json() + + def getEscrowReply(self, route=None): + """Compatibility alias for :meth:`listReply`.""" + return self.listReply(route=route) diff --git a/src/signify/app/notifying.py b/src/signify/app/notifying.py index 9f6bc8a..a316ce4 100644 --- a/src/signify/app/notifying.py +++ b/src/signify/app/notifying.py @@ -28,18 +28,22 @@ def list(self, start=0, end=24): return dict(start=start, end=end, total=total, notes=res.json()) - def markAsRead(self, nid): - """Mark one notification as read. + def mark(self, said): + """Mark one notification as read using the TS-compatible name. Parameters: - nid (str): qb64 SAID of notification to mark as read + said (str): qb64 SAID of notification to mark as read Returns: bool: ``True`` when KERIA accepts the update request. """ - res = self.client.put(f"/notifications/{nid}", json={}) + res = self.client.put(f"/notifications/{said}", json={}) return res.status_code == 202 + def markAsRead(self, nid): + """Compatibility alias for :meth:`mark`.""" + return self.mark(nid) + def delete(self, nid): """Delete one notification. diff --git a/tests/app/test_escrowing.py b/tests/app/test_escrowing.py index e309858..b0a901f 100644 --- a/tests/app/test_escrowing.py +++ b/tests/app/test_escrowing.py @@ -8,7 +8,28 @@ from mockito import mock, expect, unstub, verifyNoUnwantedInteractions -def test_escrows_get_reply_by_route(): +def test_escrows_list_reply_by_route(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.app.escrowing import Escrows + escrows = Escrows(client=mock_client) # type: ignore + + from requests import Response + mock_response = mock(spec=Response, strict=True) + + expect(mock_client, times=1).get('/escrows/rpy', params={'route': '/my_route'}).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn({'some': 'output'}) + + out = escrows.listReply(route='/my_route') + + assert out == {'some': 'output'} + + verifyNoUnwantedInteractions() + unstub() + + +def test_escrows_get_reply_alias(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) @@ -27,3 +48,24 @@ def test_escrows_get_reply_by_route(): verifyNoUnwantedInteractions() unstub() + + +def test_escrows_list_reply_without_route(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.app.escrowing import Escrows + escrows = Escrows(client=mock_client) # type: ignore + + from requests import Response + mock_response = mock(spec=Response, strict=True) + + expect(mock_client, times=1).get('/escrows/rpy', params={}).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn({'some': 'output'}) + + out = escrows.listReply() + + assert out == {'some': 'output'} + + verifyNoUnwantedInteractions() + unstub() diff --git a/tests/app/test_integration_helpers.py b/tests/app/test_integration_helpers.py index 9ab6143..618c488 100644 --- a/tests/app/test_integration_helpers.py +++ b/tests/app/test_integration_helpers.py @@ -42,7 +42,7 @@ def __init__(self): def list(self): return {"notes": [note]} - def markAsRead(self, nid): + def mark(self, nid): self.marked.append(nid) return True @@ -89,7 +89,7 @@ def __init__(self): def list(self): return {"notes": [note]} - def markAsRead(self, nid): + def mark(self, nid): self.marked.append(nid) return True @@ -176,7 +176,7 @@ def __init__(self): def list(self): return {"notes": notes} - def markAsRead(self, nid): + def mark(self, nid): self.marked.append(nid) return True diff --git a/tests/app/test_notifying.py b/tests/app/test_notifying.py index b3a6a63..7248fb3 100644 --- a/tests/app/test_notifying.py +++ b/tests/app/test_notifying.py @@ -31,7 +31,7 @@ def test_notification_list(make_mock_response): assert out['total'] == 20 assert out['notes'] == ['note1', 'note2'] -def test_notification_mark_as_read(make_mock_response): +def test_notification_mark(make_mock_response): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) @@ -41,15 +41,29 @@ def test_notification_mark_as_read(make_mock_response): mock_response = make_mock_response({'status_code': 202}) expect(mock_client, times=1).put('/notifications/ABC123', json={}).thenReturn(mock_response) - out = notes.markAsRead(nid="ABC123") + out = notes.mark(said="ABC123") assert out is True mock_response = make_mock_response({'status_code': 404}) expect(mock_client, times=1).put('/notifications/DEF456', json={}).thenReturn(mock_response) - out = notes.markAsRead(nid="DEF456") + out = notes.mark(said="DEF456") assert out is False + +def test_notification_mark_as_read_alias(make_mock_response): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.app.notifying import Notifications + notes = Notifications(client=mock_client) # type: ignore + + mock_response = make_mock_response({'status_code': 202}) + expect(mock_client, times=1).put('/notifications/ABC123', json={}).thenReturn(mock_response) + + out = notes.markAsRead(nid="ABC123") + assert out is True + def test_notification_delete(make_mock_response): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 0f22981..a86a625 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -250,7 +250,7 @@ def wait_for_notification( describe=f"notification route {route}", )[-1] # -1 for last, most recent notification if mark_read: - client.notifications().markAsRead(note["i"]) + client.notifications().mark(note["i"]) return note From aa983c642f414c3e722bb83516540ed5a47d55c7 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Tue, 24 Mar 2026 17:02:23 -0600 Subject: [PATCH 2/2] Add Operations list, delete, and wait Coded by Codex with prompts from Kent Bull --- docs/maintainer_features.rst | 34 +++- src/signify/app/coring.py | 161 ++++++++++++++- tests/app/test_coring.py | 214 ++++++++++++++++++++ tests/app/test_integration_helpers.py | 45 ++++ tests/integration/_services/keria_server.py | 4 +- tests/integration/helpers.py | 8 +- 6 files changed, 448 insertions(+), 18 deletions(-) diff --git a/docs/maintainer_features.rst b/docs/maintainer_features.rst index 1f1f6c9..6821380 100644 --- a/docs/maintainer_features.rst +++ b/docs/maintainer_features.rst @@ -84,11 +84,11 @@ Feature Inventory * - Challenge requests - ``client.challenges()`` - ``signify.app.challenging`` - - Maintained for generate/respond/verify plus acceptance tracking. + - Maintained for TS-style challenge generation, response, verification, and acceptance tracking. * - Contact requests - ``client.contacts()`` - ``signify.app.contacting`` - - Maintained as a read surface for resolved contacts. + - Maintained for TS-style contact list/get/add/update/delete, with one explicit legacy range-list path. * - Credential requests - ``client.credentials()``, ``client.ipex()`` - ``signify.app.credentialing`` @@ -244,14 +244,14 @@ Implementation: Routes: -- ``GET /challenges`` +- ``GET /challenges?strength={n}`` - ``POST /challenges_verify/{source}`` - ``PUT /challenges_verify/{source}`` - ``POST /identifiers/{name}/exchanges`` Responsibilities: -- Generate random challenge phrases. +- Generate random challenge phrases with explicit entropy strength. - Send challenge responses through the peer exchange path. - Ask KERIA to verify a signed response from a source AID. - Mark accepted challenge responses as handled. @@ -270,12 +270,19 @@ Implementation: Routes: - ``GET /contacts`` +- ``GET /contacts/{prefix}`` +- ``POST /contacts/{prefix}`` +- ``PUT /contacts/{prefix}`` +- ``DELETE /contacts/{prefix}`` Responsibilities: - Return resolved contact records already known to the agent. -- Provide the maintained read path used by the integration helper layer after - OOBI resolution and workflow choreography. +- Support TS-style query/filter lookup through ``group``, ``filter_field``, and + ``filter_value``. +- Support local metadata management for already-known remote contacts through + ``get``, ``add``, ``update``, and ``delete``. +- Preserve one explicit legacy range-list path for older Python callers. Primary tests: @@ -460,7 +467,9 @@ and ``signify.app.escrowing.Escrows`` Routes: +- ``GET /operations`` - ``GET /operations/{name}`` +- ``DELETE /operations/{name}`` - ``GET /notifications`` - ``PUT /notifications/{nid}`` - ``DELETE /notifications/{nid}`` @@ -468,11 +477,17 @@ Routes: Responsibilities: -- Poll long-running operations started by identifier, registry, delegation, and - IPEX workflows. +- Fetch, list, poll, and remove long-running operations started by identifier, + registry, delegation, and IPEX workflows. +- Use ``Operations.wait(...)`` as the local convenience poller for completion, + dependency waits, timeout control, and optional caller-controlled + cancellation hooks. - Inspect and acknowledge notification side effects created by peer and - multisig messaging. + multisig messaging. ``Notifications.mark()`` is the primary TS-style name; + ``markAsRead()`` remains a compatibility alias. - Read escrowed reply state for troubleshooting and support tooling. + ``Escrows.listReply()`` is the primary TS-style name; + ``getEscrowReply()`` remains a compatibility alias. Primary tests: @@ -491,7 +506,6 @@ Notable current gaps: - no dedicated ``schemas()`` resource wrapper yet - no dedicated ``config()`` resource wrapper yet -- ``operations()`` is currently narrow and only exposes ``get()`` When those surfaces are added, update this guide and the API reference in the same change so the published docs remain aligned with the actual maintained diff --git a/src/signify/app/coring.py b/src/signify/app/coring.py index af3be16..0942094 100644 --- a/src/signify/app/coring.py +++ b/src/signify/app/coring.py @@ -5,11 +5,13 @@ long-running operations, OOBI retrieval and resolution, key-state reads, and key-event reads. """ +import time + from signify.app.clienting import SignifyClient class Operations: - """Resource wrapper for polling long-running KERIA operations.""" + """Resource wrapper for reading and removing long-running KERIA operations.""" def __init__(self, client: SignifyClient): """Create an operations resource bound to one Signify client.""" @@ -20,6 +22,162 @@ def get(self, name): res = self.client.get(f"/operations/{name}") return res.json() + def list(self, type=None): + """List long-running operations, optionally filtered by operation type.""" + params = {} + if type is not None: + params["type"] = type + + res = self.client.get("/operations", params=params or None) + return res.json() + + def delete(self, name): + """Delete one long-running operation by operation name.""" + self.client.delete(f"/operations/{name}") + + def wait( + self, + op, + *, + timeout=None, + interval=0.01, + max_interval=10.0, + backoff=2.0, + check_abort=None, + options=None, + _deadline=None, + ): + """Poll an operation until it completes. + + Python callers should prefer the explicit keyword arguments: + ``timeout`` in seconds, ``interval`` in seconds, ``max_interval`` in + seconds, ``backoff`` as the exponential multiplier, and + ``check_abort(current_op)`` for caller-controlled cancellation. The + TS-style ``options`` dict remains supported as a compatibility path. + """ + if options is not None: + return self._wait_with_options(op, options=options) + + deadline = _deadline + if deadline is None and timeout is not None: + deadline = time.monotonic() + timeout + + depends = self._depends(op) + if depends is not None and depends.get("done") is False: + self.wait( + depends, + interval=interval, + max_interval=max_interval, + backoff=backoff, + check_abort=check_abort, + _deadline=deadline, + ) + + if op.get("done") is True: + return op + + retries = 0 + + while True: + op = self.get(op["name"]) + + if op.get("done") is True: + return op + + self._raise_if_timed_out(deadline, op) + self._check_abort(check_abort, op) + + delay = min(max_interval, interval * (backoff ** retries)) + retries += 1 + + if deadline is not None: + remaining = deadline - time.monotonic() + self._raise_if_timed_out(deadline, op) + delay = min(delay, remaining) + + time.sleep(delay) + + @staticmethod + def _depends(op): + """Return the dependent operation payload when present.""" + metadata = op.get("metadata") + if isinstance(metadata, dict): + depends = metadata.get("depends") + if isinstance(depends, dict): + return depends + + depends = op.get("depends") + if isinstance(depends, dict): + return depends + + return None + + @staticmethod + def _throw_if_aborted(signal): + """Raise the caller-provided abort signal when it has fired.""" + if signal is None: + return + + if callable(signal): + signal() + return + + if hasattr(signal, "throw_if_aborted"): + signal.throw_if_aborted() + return + + if hasattr(signal, "throwIfAborted"): + signal.throwIfAborted() + + @staticmethod + def _check_abort(check_abort, op): + """Run an optional caller-provided cancellation hook.""" + if check_abort is not None: + check_abort(op) + + @staticmethod + def _raise_if_timed_out(deadline, op): + """Raise ``TimeoutError`` when the wait deadline has expired.""" + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError( + f"timed out waiting for operation {op['name']}; last_value={op!r}" + ) + + def _wait_with_options(self, op, *, options): + """Compatibility wrapper for the TS-style wait options dictionary.""" + options = {} if options is None else options + signal = options.get("signal") + min_sleep = options.get("minSleep", 10) + max_sleep = options.get("maxSleep", 10000) + increase_factor = options.get("increaseFactor", 50) + + depends = self._depends(op) + if depends is not None and depends.get("done") is False: + self._wait_with_options(depends, options=options) + + if op.get("done") is True: + return op + + retries = 0 + + while True: + op = self.get(op["name"]) + + if hasattr(signal, "current_op"): + signal.current_op = op + + delay = max( + min_sleep, + min(max_sleep, 2 ** retries * increase_factor), + ) + retries += 1 + + if op.get("done") is True: + return op + + time.sleep(delay / 1000) + self._throw_if_aborted(signal) + class Oobis: """Resource wrapper for OOBI retrieval and resolution.""" @@ -92,4 +250,3 @@ def get(self, pre): """Fetch KERI events for one AID prefix.""" res = self.client.get(f"/events?pre={pre}") return res.json() - diff --git a/tests/app/test_coring.py b/tests/app/test_coring.py index c82a1c5..107f430 100644 --- a/tests/app/test_coring.py +++ b/tests/app/test_coring.py @@ -26,6 +26,220 @@ def test_operations(make_mock_response): ops.get("a_name") + +def test_operations_list(make_mock_response): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + mock_response = make_mock_response() + expect(client, times=1).get('/operations', params=None).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn([{'name': 'op1'}]) + + out = ops.list() + + assert out == [{'name': 'op1'}] + + +def test_operations_list_by_type(make_mock_response): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + mock_response = make_mock_response() + expect(client, times=1).get('/operations', params={'type': 'witness'}).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn([{'name': 'op1', 'type': 'witness'}]) + + out = ops.list(type="witness") + + assert out == [{'name': 'op1', 'type': 'witness'}] + + +def test_operations_delete(make_mock_response): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + mock_response = make_mock_response() + expect(client, times=1).delete('/operations/operationName').thenReturn(mock_response) + + out = ops.delete("operationName") + + assert out is None + + +def test_operations_wait_returns_done_operation_without_polling(): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + op = {"name": "operationName", "done": True} + + out = ops.wait(op) + + assert out is op + + +def test_operations_wait_polls_until_done(monkeypatch): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + responses = iter([ + {"name": "operationName", "done": False}, + {"name": "operationName", "done": True}, + ]) + calls = [] + sleeps = [] + + def fake_get(name): + calls.append(name) + return next(responses) + + monkeypatch.setattr(ops, "get", fake_get) + monkeypatch.setattr(coring.time, "sleep", lambda seconds: sleeps.append(seconds)) + + out = ops.wait( + {"name": "operationName", "done": False}, + interval=0.01, + max_interval=0.01, + ) + + assert out == {"name": "operationName", "done": True} + assert calls == ["operationName", "operationName"] + assert sleeps == [0.01] + + +def test_operations_wait_waits_for_dependency(monkeypatch): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + responses = { + "childOperation": iter([ + {"name": "childOperation", "done": True}, + ]), + "parentOperation": iter([ + { + "name": "parentOperation", + "done": True, + "metadata": {"depends": {"name": "childOperation", "done": True}}, + }, + ]), + } + calls = [] + + def fake_get(name): + calls.append(name) + return next(responses[name]) + + monkeypatch.setattr(ops, "get", fake_get) + monkeypatch.setattr(coring.time, "sleep", lambda seconds: None) + + out = ops.wait( + { + "name": "parentOperation", + "done": False, + "metadata": {"depends": {"name": "childOperation", "done": False}}, + }, + interval=0.01, + max_interval=0.01, + ) + + assert out["done"] is True + assert calls == ["childOperation", "parentOperation"] + + +def test_operations_wait_raises_when_aborted(monkeypatch): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + abort_error = RuntimeError("Aborted") + + def fake_get(name): + return {"name": name, "done": False} + + def fake_signal(current_op): + assert current_op["name"] == "operationName" + raise abort_error + + monkeypatch.setattr(ops, "get", fake_get) + monkeypatch.setattr(coring.time, "sleep", lambda seconds: None) + + with pytest.raises(RuntimeError, match="Aborted") as excinfo: + ops.wait({"name": "operationName", "done": False}, check_abort=fake_signal) + + assert excinfo.value is abort_error + + +def test_operations_wait_raises_timeout_with_latest_operation(monkeypatch): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + def fake_get(name): + return {"name": name, "done": False, "stage": "still waiting"} + + monotonic_values = iter([100.0, 101.0, 101.1]) + + monkeypatch.setattr(ops, "get", fake_get) + monkeypatch.setattr(coring.time, "sleep", lambda seconds: None) + monkeypatch.setattr(coring.time, "monotonic", lambda: next(monotonic_values)) + + with pytest.raises(TimeoutError, match="timed out waiting for operation operationName") as excinfo: + ops.wait( + {"name": "operationName", "done": False}, + timeout=1.0, + interval=0.25, + max_interval=0.25, + ) + + assert "still waiting" in str(excinfo.value) + + +def test_operations_wait_options_compatibility_path(monkeypatch): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + ops = coring.Operations(client=client) # type: ignore + + abort_error = RuntimeError("Aborted") + + def fake_get(name): + return {"name": name, "done": False} + + def fake_signal(): + raise abort_error + + monkeypatch.setattr(ops, "get", fake_get) + monkeypatch.setattr(coring.time, "sleep", lambda seconds: None) + + with pytest.raises(RuntimeError, match="Aborted") as excinfo: + ops.wait( + {"name": "operationName", "done": False}, + options={"signal": fake_signal, "maxSleep": 10}, + ) + + assert excinfo.value is abort_error + def test_oobis_get(make_mock_response): from signify.app.clienting import SignifyClient client = mock(spec=SignifyClient, strict=True) diff --git a/tests/app/test_integration_helpers.py b/tests/app/test_integration_helpers.py index 618c488..340b5b0 100644 --- a/tests/app/test_integration_helpers.py +++ b/tests/app/test_integration_helpers.py @@ -30,6 +30,51 @@ def fetch(): assert calls["count"] == 3 +def test_wait_for_operation_delegates_to_operations_wait(monkeypatch): + operation = {"name": "op-1", "done": False} + result = {"name": "op-1", "done": True} + captured = {} + + class FakeOperations: + def wait(self, op, **kwargs): + captured["op"] = op + captured["kwargs"] = kwargs + return result + + class FakeClient: + def operations(self): + return FakeOperations() + + out = helpers.wait_for_operation(FakeClient(), operation, timeout=12.5) + + assert out == result + assert captured["op"] == operation + assert captured["kwargs"]["timeout"] == 12.5 + assert captured["kwargs"]["interval"] == helpers.POLL_INTERVAL + assert captured["kwargs"]["max_interval"] == helpers.POLL_INTERVAL + assert captured["kwargs"]["backoff"] == 1.0 + + +def test_wait_for_operation_timeout_surfaces_operations_wait_error(): + operation = {"name": "op-2", "done": False} + + class FakeOperations: + def wait(self, op, **kwargs): + raise TimeoutError( + "timed out waiting for operation op-2; " + "last_value={'name': 'op-2', 'done': False, 'stage': 'still waiting'}" + ) + + class FakeClient: + def operations(self): + return FakeOperations() + + with pytest.raises(TimeoutError, match="timed out waiting for operation op-2") as excinfo: + helpers.wait_for_operation(FakeClient(), operation, timeout=1.0) + + assert "still waiting" in str(excinfo.value) + + def test_wait_for_multisig_request_waits_for_stored_request(monkeypatch): monkeypatch.setattr(helpers.time, "sleep", lambda _: None) diff --git a/tests/integration/_services/keria_server.py b/tests/integration/_services/keria_server.py index b9f8ef6..da668a2 100644 --- a/tests/integration/_services/keria_server.py +++ b/tests/integration/_services/keria_server.py @@ -5,8 +5,6 @@ import argparse import signal -from keria.app import agenting - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -19,6 +17,8 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() + from keria.app import agenting + config = agenting.KERIAServerConfig( name="keria", base="", diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index a86a625..e54f046 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -219,12 +219,12 @@ def wait_for_operation(client: SignifyClient, operation: dict, *, timeout: float if operation["done"]: return operation - return poll_until( - lambda: client.operations().get(operation["name"]), - ready=lambda current: current["done"], + return client.operations().wait( + operation, timeout=timeout, interval=POLL_INTERVAL, - describe=f"operation {operation['name']}", + max_interval=POLL_INTERVAL, + backoff=1.0, )