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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
84 changes: 82 additions & 2 deletions src/late/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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()

Expand All @@ -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.
Expand All @@ -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")
Expand All @@ -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 = {
Expand All @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion src/late/client/late_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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) ---
Expand Down
Loading