From 6dd107c6b1c704a63f8352fe9c8210467b404a77 Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Fri, 4 Sep 2026 16:31:28 +0200 Subject: [PATCH 1/2] fix(client): send an x-request-id on every request, reused across retries The SDK never emitted x-request-id, so the server could not match a replayed request to the original and its idempotency window was unreachable from Python. openapi.yaml already claims the official SDKs send one. Minted once before the retry loop so every attempt of the same logical call carries the same id; a caller-supplied id always wins. Closes the first item in PR #41's "Known gaps". --- src/late/client/base.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/late/client/base.py b/src/late/client/base.py index e204c69..2aff227 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. @@ -186,6 +193,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) @@ -333,6 +346,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) From 0ed9bcef3dc53df4db2775d4c131ceebafb7746a Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Fri, 4 Sep 2026 16:37:53 +0200 Subject: [PATCH 2/2] fix(client): stop replaying timed-out POSTs and give publishNow 300s A publishNow create publishes synchronously and can run for minutes; one measured Threads publish took 222s against a 30s DEFAULT_TIMEOUT. httpx aborted mid-publish and _request_with_retry replayed the POST, so the customer got a 409 for a post that had gone live plus a duplicate live post. POST is non-idempotent by contract, so a client-side timeout now raises instead of replaying; ConnectError still retries, since nothing reached the server. publishNow creates get a 300s timeout, overridable via Zernio(publish_timeout=...). Crisp: https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_8e5d3e6e-1e10-4a33-95f1-0b1e33d119da/ --- CHANGELOG.md | 1 + src/late/client/base.py | 65 ++++++++++++++++++++++++++++++++-- src/late/client/late_client.py | 10 +++++- 3 files changed, 73 insertions(+), 3 deletions(-) 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 2aff227..479e221 100644 --- a/src/late/client/base.py +++ b/src/late/client/base.py @@ -68,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() @@ -78,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. @@ -87,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") @@ -95,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 = { @@ -104,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.""" @@ -213,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: @@ -269,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( @@ -364,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: @@ -418,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) ---