diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9524b..35757d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to `citrate-labs-sdk` are documented here. This project adhe ## [Unreleased] +## [0.8.0] - 2026-11-08 — contract verification on CitrateScan + +Minor release: new API only, nothing removed or changed in an incompatible way. Publish once the +CitrateScan verify API (v2) serves `verifysourcecode` / `checkverifystatus` at +`DEFAULT_EXPLORER_URL`. + ### Added - `citrate_sdk.verify`: `verify_contract` and `deploy_and_verify` verify Solidity source on @@ -22,6 +28,13 @@ All notable changes to `citrate-labs-sdk` are documented here. This project adhe resubmissions wait at least `rate_limit_backoff`; transport-error messages never include the API key or the request's query string. +### Fixed (review follow-up on #21) + +- `wait_for_verification`: the transient backoff exponent is clamped (`transient_backoff`) and + `poll_interval` is at least `MIN_POLL_INTERVAL` (5 ms), so `poll_interval=0` no longer retries in + a tight loop and `poll_interval=0.0` with `transient_backoff_max=0.0` can no longer raise + `OverflowError` after 1024 consecutive transient errors. + ## [0.7.0] - 2026-10-27 — r1006 address book and a genesis guard Held for the r1006 public cutover: publish only after rpc.citrate.ai serves r1006. diff --git a/README.md b/README.md index f3edb52..3d36c49 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,8 @@ Keywords: - `poll_interval` (3 s) and `timeout` (300 s) for the status polling. A transient error while polling (connection reset, request timeout, HTTP 5xx or 429) does not end the wait: the poll is retried with a backoff that doubles from `poll_interval` up to `transient_backoff_max` (30 s), - within `timeout`. A 4xx other than 429, or a `Fail` verdict, ends it. + within `timeout`. A 4xx other than 429, or a `Fail` verdict, ends it. `poll_interval` is at + least `MIN_POLL_INTERVAL` (5 ms), so 0 does not retry in a tight loop. - `rate_limit_retries` (3) and `rate_limit_backoff` (5 s) for `Max rate limit reached`. The explorer takes one submission per 5 s per IP, so while it has not indexed a just-deployed contract, `deploy_and_verify` resubmits at most every `max(poll_interval, rate_limit_backoff)` diff --git a/citrate_sdk/__init__.py b/citrate_sdk/__init__.py index c424ded..d2f6cb4 100644 --- a/citrate_sdk/__init__.py +++ b/citrate_sdk/__init__.py @@ -64,7 +64,7 @@ wait_for_verification, ) -__version__ = "0.7.0" +__version__ = "0.8.0" __author__ = "Citrate Team" __all__ = [ diff --git a/citrate_sdk/verify.py b/citrate_sdk/verify.py index 5214ae7..dee9dfe 100644 --- a/citrate_sdk/verify.py +++ b/citrate_sdk/verify.py @@ -32,6 +32,13 @@ from .errors import CitrateError DEFAULT_EXPLORER_URL = "https://explorer.citrate.ai/api" + +#: Shortest wait in seconds between two status polls, whatever ``poll_interval`` says (0 would +#: retry in a tight loop). +MIN_POLL_INTERVAL = 0.005 + +#: The doubling stops here; 2**30 times any interval already exceeds every sensible cap. +_MAX_BACKOFF_EXPONENT = 30 #: Explorer limits (CitrateScan docs, "Limits"). MAX_SOURCE_BYTES = 2 * 1024 * 1024 MAX_SOURCE_FILES = 64 @@ -528,6 +535,19 @@ def check_verify_status( return VerifyStatus("failed", text) +def transient_backoff(poll_interval: float, transient_errors: int, transient_backoff_max: float) -> float: + """The wait in seconds after the ``transient_errors``-th consecutive transient error. + + ``poll_interval`` (at least :data:`MIN_POLL_INTERVAL`) doubled per error, capped at + ``transient_backoff_max`` (or the interval, if larger). The exponent is clamped, so the result + is finite for any error count and never raises ``OverflowError``. + """ + base = max(float(poll_interval), MIN_POLL_INTERVAL) + exponent = min(max(transient_errors - 1, 0), _MAX_BACKOFF_EXPONENT) + delay: float = base * 2.0**exponent + return min(delay, max(float(transient_backoff_max), base)) + + def wait_for_verification( guid: str, *, @@ -544,8 +564,10 @@ def wait_for_verification( A transient :class:`ExplorerResponseError` (transport failure, HTTP 5xx or 429) does not end the wait: the poll is retried after a backoff that doubles from ``poll_interval`` up to - ``transient_backoff_max`` seconds, within ``timeout``. Any other error ends it. + ``transient_backoff_max`` seconds (see :func:`transient_backoff`), within ``timeout``. Any other + error ends it. ``poll_interval`` is at least :data:`MIN_POLL_INTERVAL`. """ + poll_interval = max(float(poll_interval), MIN_POLL_INTERVAL) deadline = time.monotonic() + timeout transient_errors = 0 while True: @@ -563,7 +585,7 @@ def wait_for_verification( if not e.transient: raise transient_errors += 1 - delay = min(poll_interval * 2 ** (transient_errors - 1), max(transient_backoff_max, poll_interval)) + delay = transient_backoff(poll_interval, transient_errors, transient_backoff_max) last = str(e) else: if s.state == "verified": diff --git a/pyproject.toml b/pyproject.toml index 04d3c14..f9c6a0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta" [project] name = "citrate-labs-sdk" -version = "0.7.0" +version = "0.8.0" description = "Python SDK for the Citrate distributed AI network (chain 40204). The canonical TypeScript SDK is @citratelabs/sdk on npm; this Python client is opt-in and may lag it." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_verify.py b/tests/test_verify.py index 5485dd9..6af4288 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import math import socket import threading import time @@ -20,6 +21,7 @@ from urllib.parse import parse_qsl, urlsplit import pytest +import requests from eth_abi import encode import citrate_sdk @@ -27,6 +29,7 @@ from citrate_sdk.errors import CitrateError from citrate_sdk.verify import ( DEFAULT_EXPLORER_URL, + MIN_POLL_INTERVAL, BuildInfoError, ExplorerResponseError, VerificationFailedError, @@ -39,6 +42,7 @@ load_build_info, parse_build_info, prepare_standard_json, + transient_backoff, verify_contract, wait_for_verification, ) @@ -422,6 +426,53 @@ def test_backoff_between_transient_errors_is_bounded(recorder: Any) -> None: assert want - 0.003 <= got < want + 0.2 +class _AlwaysResetSession(requests.Session): + """Test-only session: every GET fails like a connection reset, and is counted.""" + + def __init__(self) -> None: + super().__init__() + self.calls = 0 + + def get(self, url: Any, **kwargs: Any) -> Any: + self.calls += 1 + raise requests.ConnectionError("connection reset by peer") + + +def test_zero_poll_interval_and_backoff_max_still_back_off() -> None: + # Both 0.0: the doubling stayed at 0 (a tight retry loop) and 0.0 * 2**1024 raised + # OverflowError after 1024 consecutive transient errors instead of timing out. + session = _AlwaysResetSession() + t0 = time.monotonic() + with pytest.raises(VerificationTimeoutError) as e: + wait_for_verification(GUID, explorer_url="https://explorer.example/api", session=session, + poll_interval=0.0, transient_backoff_max=0.0, timeout=0.3) + assert "ConnectionError" in e.value.last_status + # MIN_POLL_INTERVAL (5 ms) between attempts: about 60 calls in 0.3 s, never thousands. + assert 2 <= session.calls <= 0.3 / MIN_POLL_INTERVAL + 5 + assert time.monotonic() - t0 < 2.0 + + +def test_zero_poll_interval_backoff_doubles_to_the_cap() -> None: + session = _AlwaysResetSession() + with pytest.raises(VerificationTimeoutError): + wait_for_verification(GUID, explorer_url="https://explorer.example/api", session=session, + poll_interval=0, transient_backoff_max=1.0, timeout=0.3) + # 5, 10, 20, 40, 80, 160 ms fits 0.3 s in about 7 calls. + assert 2 <= session.calls < 12 + + +def test_transient_backoff_is_finite_and_capped() -> None: + assert transient_backoff(0.02, 1, 0.05) == 0.02 + assert transient_backoff(0.02, 2, 0.05) == 0.04 + assert transient_backoff(0.02, 3, 0.05) == 0.05 + for n in (1, 30, 1024, 1025, 5000, 10**18): + for interval in (0.0, 0.001, 3.0): + for cap in (0.0, 30.0): + d = transient_backoff(interval, n, cap) + assert math.isfinite(d) + assert MIN_POLL_INTERVAL <= d <= max(cap, interval, MIN_POLL_INTERVAL) + + def test_transport_failures_5xx_and_429_are_transient(recorder: Any) -> None: cases: list[tuple[tuple[int, str], int | None, bool]] = [ ((503, "down"), 503, True), diff --git a/uv.lock b/uv.lock index 470f67d..b076d06 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "citrate-labs-sdk" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "cryptography" },