diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ce038..2e2b09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **A POST that timed out client-side could be silently replayed, creating a duplicate live post.** `_request_with_retry` (and its async twin) retried every transient error identically, including `httpx.TimeoutException` on a POST. A timeout only means the client gave up waiting - the server may have finished the request anyway - so replaying it can create a second copy of whatever the first attempt already did. Hit in practice on a `publishNow` create: the server-side publish took 222s against `BaseClient.DEFAULT_TIMEOUT`'s 30s, httpx aborted while the server kept working, and the retry loop fired a second identical POST. The SDK sent no request id, so the server couldn't recognize the replay, and its content-hash dedup answered the replay with a 409 while the original request was still live - so the customer saw a failure for a post that had actually published, retried by hand with a one-character caption change to dodge the dedup, and ended up with two live posts. Two independent fixes: (1) every request now carries an `x-request-id` header, minted once per call and reused across retry attempts, so the server can recognize a replay when one does happen; (2) a POST that times out is no longer retried at all - it raises immediately with a message naming the duplicate-post risk, because the server's content-hash dedup can still race ahead of its idempotency check even with a matching request id. `publishNow` creates also get a much longer timeout (`publish_timeout`, default 300s, configurable on `Zernio(...)`) than the SDK default (`timeout`, default 30s), since a publish-now create runs the whole cross-platform publish synchronously inside the request. PUT, PATCH, and DELETE are unaffected - they're idempotent by contract and stay retryable on timeout. Known gaps, left alone here and tracked for follow-up: 5xx responses are never retried, and `PUT /v1/posts/{id}` with `publishNow` has the same synchronous-publish timeout exposure as create. - **MCP `accounts_get_follower_stats` returned only the account name, dropping the follower count and daily series.** The shared `_format_response` helper pattern-matches on the response shape, and `FollowerStatsResponse` has an `accounts` attribute, so it fell into the generic account-list branch that prints only `- {platform}: {username}` and silently discarded `currentFollowers`, `growth`, and the daily `stats` series. Hit in practice by a developer pulling LinkedIn org follower stats (data was present server-side: latest count plus a week of daily snapshots), who saw only the account name come back through the tool. `_format_response` now checks for a `stats` attribute (unique to `FollowerStatsResponse` among all response models) BEFORE the generic `accounts` branch and returns the full `model_dump_json(by_alias=True, exclude_none=True)`, so the count, growth, and series reach the LLM losslessly. Fixed in both the emitted `generated_tools.py` and the `generate_mcp_tools.py` template so a future regen keeps it. Two regression tests added in `tests/test_integration.py`. (The related model gap, `FollowerStatsResponse` missing `stats`/`granularity`, was already corrected on `develop` by an earlier OpenAPI regen, so no model change was needed here.) ## [1.4.49] diff --git a/src/late/client/base.py b/src/late/client/base.py index e204c69..479e221 100644 --- a/src/late/client/base.py +++ b/src/late/client/base.py @@ -5,6 +5,7 @@ from __future__ import annotations import time +import uuid from contextlib import asynccontextmanager, contextmanager from importlib.metadata import PackageNotFoundError, version from typing import TYPE_CHECKING, Any @@ -51,6 +52,12 @@ def _parse_error_body(response: httpx.Response) -> dict[str, Any]: return data if isinstance(data, dict) else {} +def _with_request_id(headers: dict[str, str] | None) -> dict[str, str]: + merged = dict(headers or {}) + merged.setdefault("x-request-id", str(uuid.uuid4())) + return merged + + class BaseClient: """ Base HTTP client supporting both sync and async operations. @@ -61,6 +68,12 @@ class BaseClient: DEFAULT_BASE_URL = "https://zernio.com/api" DEFAULT_TIMEOUT = 30.0 + # A publishNow create runs the whole cross-platform publish inside the + # request. One measured Threads publish took 222s against DEFAULT_TIMEOUT's + # 30s, so httpx aborted while the server was still working and the retry + # loop replayed the POST - two live posts, and a 409 for the one that + # actually published. Crisp session_8e5d3e6e-1e10-4a33-95f1-0b1e33d119da. + DEFAULT_PUBLISH_TIMEOUT = 300.0 DEFAULT_MAX_RETRIES = 3 SDK_VERSION = _resolve_sdk_version() @@ -71,6 +84,7 @@ def __init__( base_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, + publish_timeout: float = DEFAULT_PUBLISH_TIMEOUT, ) -> None: """ Initialize the base client. @@ -80,6 +94,9 @@ def __init__( base_url: Base URL for the API (default: https://zernio.com/api) timeout: Request timeout in seconds (default: 30) max_retries: Maximum retries for failed requests (default: 3) + publish_timeout: Timeout in seconds for publishNow creates, which + publish synchronously and can outlast `timeout` + (default: 300) """ if not api_key: raise ValueError("API key is required") @@ -88,6 +105,7 @@ def __init__( self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/") self.timeout = timeout self.max_retries = max_retries + self.publish_timeout = publish_timeout self._rate_limiter = RateLimiter() self._headers = { @@ -97,6 +115,20 @@ def __init__( "User-Agent": f"late-python-sdk/{self.SDK_VERSION}", } + def _resolve_timeout(self, data: dict[str, Any] | None) -> float: + """ + Pick the request timeout by sniffing publishNow out of the JSON body. + + Sniffing a domain field in the transport layer is a deliberate stopgap. + It is the only place that covers all three publish callers at once - the + hand-written posts.create, the generated create_post, and the MCP server - + and it survives regeneration, which base.py does and _generated/ does not. + The proper fix is for scripts/generate_resources.py to emit an explicit + timeout= on publish-capable operations; that needs a 58-file regen and is + deliberately out of scope here. + """ + return self.publish_timeout if (data or {}).get("publishNow") else self.timeout + @property def rate_limit_info(self) -> dict[str, Any]: """Get current rate limit information.""" @@ -186,6 +218,12 @@ def _request_with_retry( """Make a request with automatic retry on transient errors.""" last_error: Exception | None = None + # Mint the id once, outside the loop: every attempt must carry the SAME + # x-request-id or the server cannot match a replay to the original. httpx + # copies this dict per attempt rather than mutating it, so one assignment + # here is genuinely reused. setdefault keeps a caller-supplied id. + kwargs["headers"] = _with_request_id(kwargs.get("headers")) + for attempt in range(self.max_retries): try: response = client.request(method, path, **kwargs) @@ -200,6 +238,18 @@ def _request_with_retry( raise except httpx.TimeoutException as e: + if method.upper() == "POST": + last_error = LateTimeoutError( + f"POST {path} timed out and was NOT retried: the request may have " + f"completed server-side. Check before retrying; retrying may create " + f"a duplicate. ({e})" + ) + # A POST that timed out client-side may have fully succeeded server-side: + # replaying it creates a second live post. The server keys idempotency on + # x-request-id, but its content-hash dedup runs first and answers 409 while + # the original is still publishing, so the window is unreachable. PUT, + # PATCH and DELETE stay retryable - they are idempotent by contract. + raise last_error from e last_error = LateTimeoutError(f"Request timed out: {e}") except httpx.ConnectError as e: @@ -256,7 +306,13 @@ def _post( with self._sync_client() as client: return self._request_with_retry( - client, "POST", path, json=data, params=params, headers=headers + client, + "POST", + path, + json=data, + params=params, + headers=headers, + timeout=self._resolve_timeout(data), ) def _put( @@ -333,6 +389,12 @@ async def _arequest_with_retry( last_error: Exception | None = None + # Mint the id once, outside the loop: every attempt must carry the SAME + # x-request-id or the server cannot match a replay to the original. httpx + # copies this dict per attempt rather than mutating it, so one assignment + # here is genuinely reused. setdefault keeps a caller-supplied id. + kwargs["headers"] = _with_request_id(kwargs.get("headers")) + for attempt in range(self.max_retries): try: response = await client.request(method, path, **kwargs) @@ -345,6 +407,18 @@ async def _arequest_with_retry( raise except httpx.TimeoutException as e: + if method.upper() == "POST": + last_error = LateTimeoutError( + f"POST {path} timed out and was NOT retried: the request may have " + f"completed server-side. Check before retrying; retrying may create " + f"a duplicate. ({e})" + ) + # A POST that timed out client-side may have fully succeeded server-side: + # replaying it creates a second live post. The server keys idempotency on + # x-request-id, but its content-hash dedup runs first and answers 409 while + # the original is still publishing, so the window is unreachable. PUT, + # PATCH and DELETE stay retryable - they are idempotent by contract. + raise last_error from e last_error = LateTimeoutError(f"Request timed out: {e}") except httpx.ConnectError as e: @@ -399,7 +473,13 @@ async def _apost( async with self._async_client() as client: return await self._arequest_with_retry( - client, "POST", path, json=data, params=params, headers=headers + client, + "POST", + path, + json=data, + params=params, + headers=headers, + timeout=self._resolve_timeout(data), ) async def _aput( diff --git a/src/late/client/late_client.py b/src/late/client/late_client.py index d424d15..dc22ef4 100644 --- a/src/late/client/late_client.py +++ b/src/late/client/late_client.py @@ -113,6 +113,7 @@ def __init__( base_url: str | None = None, timeout: float = 30.0, max_retries: int = 3, + publish_timeout: float = 300.0, ) -> None: """ Initialize the Zernio client. @@ -123,6 +124,9 @@ def __init__( base_url: Base URL (default: https://zernio.com/api) timeout: Request timeout in seconds max_retries: Maximum retries for failed requests + publish_timeout: Timeout in seconds for publishNow creates, which + publish synchronously and can outlast `timeout` + (default: 300) Raises: ValueError: If no API key is provided and neither ZERNIO_API_KEY @@ -137,7 +141,11 @@ def __init__( ) super().__init__( - resolved_key, base_url=base_url, timeout=timeout, max_retries=max_retries + resolved_key, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + publish_timeout=publish_timeout, ) # --- auto-registered resources (do not edit) ---