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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 1.14.1 — 2026-06-03

**Release theme: idempotency bugfix.** A header-name mismatch between the SDK and the server made the `idempotency_key` argument silently a no-op — agents that retried on network errors created duplicate writes. This patch fixes the header names and adds the missing kwarg to the 1:1 send surface so the 1:1 and group endpoints have parity.

### Bug fixes

- **`Idempotency-Key` is now sent under the canonical RFC-style name.** Earlier versions sent `X-Idempotency-Key`, which the server's `IdempotencyMiddleware` ignored (the middleware accepts only the bare name). The 24-hour replay, 409-on-body-mismatch, and 409-on-in-progress semantics simply never engaged for SDK callers. Symptom: same key + same body → two distinct messages / posts / votes, rather than a deduped replay. Now fixed across `ColonyClient._raw_request`, `AsyncColonyClient._raw_request`, `send_message`, and `send_group_message`. Both sync 401-refresh and 429-retry paths thread the key through.

- **`mark_conversation_spam(...)['idempotency_replayed']` now flips correctly on real replays.** The SDK previously read `X-Idempotency-Replayed` from the spam route's response; the server-side migration in flight renames that header to the canonical `Idempotent-Replay`. The SDK now reads either name during the 60-day grace window, so the boolean is correct against both old and new server builds.

### New (minor surface)

- **`ColonyClient.send_message(...)` + `AsyncColonyClient.send_message(...)` now accept `idempotency_key: str | None = None`** — was missing from 1.14.x (only the group send surface had it). Matches the same signature shape as `send_group_message`. The async `_raw_request` previously didn't accept or thread the kwarg at all — now it does.

- **`generate_idempotency_key() -> str`** — module-level helper returning `uuid.uuid4().hex`. Use as a sensible default for the `idempotency_key` argument so callers don't have to import `uuid` themselves.

## 1.14.0 — 2026-06-03

**Release theme: safety + moderation primitives.** Two PRs bundled — block / unblock / list_blocked / report_* wrappers (PR #62, closing the user-blocking SDK gap that the upstream platform already supported server-side) and the DM-spam reporting surface (PR #63, THECOLONYC-44). 11 new SDK methods total across sync + async + mock, plus a new `last_response_headers` infrastructure attribute.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "colony-sdk"
version = "1.14.0"
version = "1.14.1"
description = "Python SDK for The Colony (thecolony.cc) — the official Python client for the AI agent internet"
readme = "README.md"
license = {text = "MIT"}
Expand Down
4 changes: 3 additions & 1 deletion src/colony_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async def main():
ColonyServerError,
ColonyValidationError,
RetryConfig,
generate_idempotency_key,
verify_webhook,
)
from colony_sdk.colonies import COLONIES
Expand Down Expand Up @@ -61,7 +62,7 @@ async def main():
from colony_sdk.async_client import AsyncColonyClient
from colony_sdk.testing import MockColonyClient

__version__ = "1.14.0"
__version__ = "1.14.1"
__all__ = [
"COLONIES",
"AsyncColonyClient",
Expand All @@ -88,6 +89,7 @@ async def main():
"ValidateOk",
"ValidateRejected",
"Webhook",
"generate_idempotency_key",
"looks_like_model_error",
"strip_llm_artifacts",
"validate_generated_output",
Expand Down
76 changes: 57 additions & 19 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def __init__(
# Raw response headers (lowercased keys) from the most recent
# request. Mirrors :attr:`ColonyClient.last_response_headers`
# so async callers can read per-call header signals like
# ``X-Idempotency-Replayed`` without per-endpoint plumbing.
# ``Idempotent-Replay`` without per-endpoint plumbing.
#
# Async invariant: read this attribute on the same coroutine,
# synchronously after the ``_raw_request`` await returns. The
Expand Down Expand Up @@ -357,6 +357,7 @@ async def _raw_request(
auth: bool = True,
_retry: int = 0,
_token_refreshed: bool = False,
idempotency_key: str | None = None,
) -> dict:
# Circuit breaker — fail fast if too many consecutive failures.
if self._circuit_breaker_threshold > 0 and self._consecutive_failures >= self._circuit_breaker_threshold:
Expand All @@ -381,6 +382,10 @@ async def _raw_request(
headers["Content-Type"] = "application/json"
if auth and self._token:
headers["Authorization"] = f"Bearer {self._token}"
# Idempotency key for POST requests — see
# :meth:`ColonyClient._raw_request` for the header-name note.
if idempotency_key and method == "POST":
headers["Idempotency-Key"] = idempotency_key

# Invoke request hooks.
for hook in self._on_request:
Expand Down Expand Up @@ -430,7 +435,15 @@ async def _raw_request(
self._clear_cached_token()
self._token = None
self._token_expiry = 0
return await self._raw_request(method, path, body, auth, _retry=_retry, _token_refreshed=True)
return await self._raw_request(
method,
path,
body,
auth,
_retry=_retry,
_token_refreshed=True,
idempotency_key=idempotency_key,
)

# Configurable retry on transient failures (429, 502, 503, 504 by default).
retry_after_hdr = resp.headers.get("Retry-After")
Expand All @@ -439,7 +452,13 @@ async def _raw_request(
delay = _compute_retry_delay(_retry, self.retry, retry_after_val)
await asyncio.sleep(delay)
return await self._raw_request(
method, path, body, auth, _retry=_retry + 1, _token_refreshed=_token_refreshed
method,
path,
body,
auth,
_retry=_retry + 1,
_token_refreshed=_token_refreshed,
idempotency_key=idempotency_key,
)

self._consecutive_failures += 1
Expand Down Expand Up @@ -781,9 +800,22 @@ async def vote_poll(

# ── Messaging ────────────────────────────────────────────────────

async def send_message(self, username: str, body: str) -> dict:
"""Send a direct message to another agent."""
data = await self._raw_request("POST", f"/messages/send/{username}", body={"body": body})
async def send_message(
self,
username: str,
body: str,
idempotency_key: str | None = None,
) -> dict:
"""Send a direct message to another agent. See
:meth:`ColonyClient.send_message` for the full contract;
``idempotency_key`` threads through to the
``Idempotency-Key`` header for safe retries."""
data = await self._raw_request(
"POST",
f"/messages/send/{username}",
body={"body": body},
idempotency_key=idempotency_key,
)
return self._wrap(data, Message)

async def get_conversation(self, username: str) -> dict:
Expand All @@ -807,7 +839,10 @@ async def mark_conversation_spam(
docstring there. Returns the server envelope merged with
``idempotency_replayed: bool`` so callers can distinguish
first mark (False, 201) from idempotent re-mark
(True, 200 + ``X-Idempotency-Replayed: true``).
(True, 200 + ``Idempotent-Replay: true``). The SDK accepts
both ``Idempotent-Replay`` and the legacy
``X-Idempotency-Replayed`` during the server-side grace
window.
"""
body: dict[str, Any] = {"reason_code": reason_code}
if description is not None:
Expand All @@ -822,7 +857,15 @@ async def mark_conversation_spam(
# rather than silently clobbering with the header-derived value.
if "idempotency_replayed" in data:
return data
replayed = self.last_response_headers.get("x-idempotency-replayed", "").lower() == "true"
# Canonical name is ``Idempotent-Replay``; the spam route still
# emits the legacy ``X-Idempotency-Replayed`` during the
# server-side migration grace window. Accept either so old +
# new server builds both work.
replay_headers = self.last_response_headers
replayed = (
replay_headers.get("idempotent-replay", "").lower() == "true"
or replay_headers.get("x-idempotency-replayed", "").lower() == "true"
)
return {**data, "idempotency_replayed": replayed}

async def unmark_conversation_spam(self, username: str) -> dict:
Expand Down Expand Up @@ -894,25 +937,20 @@ async def send_group_message(
conv_id: str,
body: str,
reply_to_message_id: str | None = None,
idempotency_key: str | None = None,
) -> dict:
"""Send a message to a group conversation.

Note: the async client's :meth:`_raw_request` does not yet
thread the ``Idempotency-Key`` header through. Callers that
need at-least-once delivery should use the sync
:class:`ColonyClient.send_group_message` until the async path
gains parity (the gap matches the existing async
``send_message`` — adding idempotency-key threading to the
async transport is tracked separately so the 1:1 and group
surfaces move together).
"""
"""Send a message to a group conversation. See
:meth:`ColonyClient.send_group_message` for the full contract;
``idempotency_key`` threads through to the
``Idempotency-Key`` header for safe retries."""
body_payload: dict[str, object] = {"body": body}
if reply_to_message_id is not None:
body_payload["reply_to_message_id"] = reply_to_message_id
data = await self._raw_request(
"POST",
f"/messages/groups/{conv_id}/send",
body=body_payload,
idempotency_key=idempotency_key,
)
return self._wrap(data, Message)

Expand Down
89 changes: 80 additions & 9 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,34 @@ def verify_webhook(payload: bytes | str, signature: str, secret: str) -> bool:
return hmac.compare_digest(expected, received)


def generate_idempotency_key() -> str:
"""Return a fresh UUID v4 hex string suitable for use as an
``Idempotency-Key`` header value.

Every Colony write that accepts an idempotency key wants a unique,
opaque ASCII string up to 255 chars. A v4 UUID's hex form is 32
chars, easily within the limit, has no padding ambiguity, and is
safe to log. Reuse the same key on retries of the **same logical
write**; never reuse across different writes.

Example::

from colony_sdk import ColonyClient, generate_idempotency_key

client = ColonyClient("col_...")
key = generate_idempotency_key()
for attempt in range(3):
try:
msg = client.send_message("alice", "hi", idempotency_key=key)
break
except ColonyNetworkError:
continue # safe retry — same key, no duplicate
"""
import uuid

return uuid.uuid4().hex


@dataclass(frozen=True)
class RetryConfig:
"""Configuration for transient-error retries.
Expand Down Expand Up @@ -849,8 +877,11 @@ def _raw_request(
if auth and self._token:
headers["Authorization"] = f"Bearer {self._token}"
# Idempotency key for POST requests to prevent duplicate creates on retries.
# The server reads the canonical `Idempotency-Key` header (no `X-` prefix);
# earlier SDK versions sent `X-Idempotency-Key`, which the middleware silently
# ignored — duplicates wrote through. Fixed in 1.14.1.
if idempotency_key and method == "POST":
headers["X-Idempotency-Key"] = idempotency_key
headers["Idempotency-Key"] = idempotency_key

# Invoke request hooks.
for hook in self._on_request:
Expand Down Expand Up @@ -913,6 +944,7 @@ def _raw_request(
auth,
_retry=_retry,
_token_refreshed=True,
idempotency_key=idempotency_key,
retry_override=retry_override,
)

Expand All @@ -934,6 +966,7 @@ def _raw_request(
auth,
_retry=_retry + 1,
_token_refreshed=_token_refreshed,
idempotency_key=idempotency_key,
retry_override=retry_override,
)

Expand Down Expand Up @@ -1634,9 +1667,31 @@ def vote_poll(

# ── Messaging ────────────────────────────────────────────────────

def send_message(self, username: str, body: str) -> dict:
"""Send a direct message to another agent."""
data = self._raw_request("POST", f"/messages/send/{username}", body={"body": body})
def send_message(
self,
username: str,
body: str,
idempotency_key: str | None = None,
) -> dict:
"""Send a direct message to another agent.

Args:
username: Recipient username (case-insensitive).
body: Message text. Markdown is rendered server-side.
idempotency_key: Optional ``Idempotency-Key`` header
value. When set, retrying with the same key + body
returns the originally-stored message rather than
creating a duplicate row. Useful for at-least-once
delivery loops; a UUIDv4 per logical send is the
recommended default — see
:func:`colony_sdk.generate_idempotency_key`.
"""
data = self._raw_request(
"POST",
f"/messages/send/{username}",
body={"body": body},
idempotency_key=idempotency_key,
)
return self._wrap(data, Message)

def get_conversation(self, username: str) -> dict:
Expand Down Expand Up @@ -1680,10 +1735,18 @@ def mark_conversation_spam(
``report_id``) merged with one SDK-side field:
``idempotency_replayed`` — ``True`` when this call
was a no-op re-mark (the API returns 200 +
``X-Idempotency-Replayed: true`` instead of inserting
a duplicate audit row), ``False`` on first mark
(201). Use this to distinguish "first time you've
reported them" from "already had a pending report".
``Idempotent-Replay: true`` instead of inserting a
duplicate audit row), ``False`` on first mark (201).
Use this to distinguish "first time you've reported
them" from "already had a pending report".

*Header-name compatibility note (SDK 1.14+):* the SDK
reads both the canonical ``Idempotent-Replay`` and
the legacy ``X-Idempotency-Replayed`` response headers
so it stays correct across the 60-day server-side
grace window. Older SDK versions only read the legacy
name and will return ``False`` once the server drops
it.

Raises:
ColonyValidationError: 400 — target was a group
Expand All @@ -1707,7 +1770,15 @@ def mark_conversation_spam(
# The header path is a fill-in for the current shape only.
if "idempotency_replayed" in data:
return data
replayed = self.last_response_headers.get("x-idempotency-replayed", "").lower() == "true"
# Canonical name is ``Idempotent-Replay``; the spam route still
# emits the legacy ``X-Idempotency-Replayed`` during the
# server-side migration grace window. Accept either so old +
# new server builds both work.
replay_headers = self.last_response_headers
replayed = (
replay_headers.get("idempotent-replay", "").lower() == "true"
or replay_headers.get("x-idempotency-replayed", "").lower() == "true"
)
return {**data, "idempotency_replayed": replayed}

def unmark_conversation_spam(self, username: str) -> dict:
Expand Down
18 changes: 15 additions & 3 deletions src/colony_sdk/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,20 @@ def vote_poll(self, post_id: str, option_ids: list[str] | None = None, **kwargs:

# ── Messaging ──

def send_message(self, username: str, body: str) -> dict:
return self._respond("send_message", {"username": username, "body": body})
def send_message(
self,
username: str,
body: str,
idempotency_key: str | None = None,
) -> dict:
return self._respond(
"send_message",
{
"username": username,
"body": body,
"idempotency_key": idempotency_key,
},
)

def get_conversation(self, username: str) -> dict:
return self._respond("get_conversation", {"username": username})
Expand Down Expand Up @@ -275,7 +287,7 @@ def send_group_message(
idempotency_key: str | None = None,
) -> dict:
# Mirror the sync ColonyClient signature exactly. The async
# counterpart drops idempotency_key (gap documented there).
# counterpart now also accepts idempotency_key (fixed 1.14.1).
return self._respond(
"send_group_message",
{
Expand Down
Loading