correspond.idempotency#

Idempotent sends: an idempotency_key keeps a message from going out twice.

A channel can post a message and then fail: the platform accepted the post and the connection dropped before its answer, or a call after the post raised. send() then reports ok=False, and whoever sends it again (a retry, an operator releasing the draft by hand) posts it a second time. With idempotency_key= send() remembers, in a store of its own (sends=, by default files under the data root), what each key did:

  • right before the real write the key is claimed (ATTEMPTED), and after it the key is SENT, with the result; a failure the platform reported before accepting anything (NOTHING_POSTED_KINDS) marks it FAILED, which a new try may claim again;

  • the same key after SENT posts nothing and answers the stored result, marked a replay;

  • the same key after an attempt whose outcome is unknown (still ATTEMPTED: a network or platform failure, a crash) reads the conversation back, where the channel can be read, for a message by correspond’s own account with the same text since the attempt. Found, that message is the result, and nothing is posted. Not found, or not readable, the send is not made: it fails with error_kind="unconfirmed", saying to check the conversation, since a reader may not show a message that did go out (an email’s inbox does not list what was sent, and Telegram’s posted text starts with the title). The caller who has checked sends with a new key;

  • a second send with a key another one claimed meanwhile (a retry that overlapped the first) writes nothing: the default store claims a key with an exclusive create, so of two processes exactly one sends;

  • a key used again for another conversation or another draft is refused (validation).

A dry run reads the store and writes nothing. Nothing is added to the text posted.

>>> record = claimed("k1", "fake:example/demo", fingerprint="f", at=parse_time("2026-09-22T12:00:00Z"))
>>> record["state"], record["attempted_at"]
('attempted', '2026-09-22T12:00:00+00:00')

Module Attributes

ATTEMPTED

The key was claimed for a real write whose outcome is not known yet (or never became known).

SENT

The write went out; the record keeps its result.

FAILED

The platform refused the write before accepting anything; the key may be claimed again.

NOTHING_POSTED_KINDS

The error_kind of failures that mean the platform accepted nothing, so a new try is safe.

READ_BACK_SKEW

How far before an attempt a message read back may be dated and still be that attempt's: the platform's clock and this machine's differ.

MAX_KEY_LENGTH

The longest key accepted (it names a file in the default store).

Functions

check_key(key)

key stripped, or ValueError for one that is not a non-blank string of at most MAX_KEY_LENGTH characters.

attempted_at(record)

When record's key was claimed.

claimed(key, conversation, *, fingerprint, at)

The record of key claimed at at for a real write to conversation.

fingerprint(conversation, draft)

What a key is bound to: the conversation and everything the draft says, as SHA-256 hex.

matching_message(messages, draft, *, since)

The latest of messages written by correspond's own account with draft's text, at or after since less READ_BACK_SKEW.

now()

The moment a key is claimed (UTC).

settled(record, state, result)

record moved to state (SENT or FAILED), with the write's result.

correspond.idempotency.ATTEMPTED = 'attempted'#

The key was claimed for a real write whose outcome is not known yet (or never became known).

correspond.idempotency.FAILED = 'failed'#

The platform refused the write before accepting anything; the key may be claimed again.

correspond.idempotency.MAX_KEY_LENGTH = 128#

The longest key accepted (it names a file in the default store).

correspond.idempotency.NOTHING_POSTED_KINDS = frozenset({'auth', 'not_found', 'permission', 'rate_limited'})#

The error_kind of failures that mean the platform accepted nothing, so a new try is safe. A network failure and an unavailable platform are not among them: the post may have landed. validation is not among them either: a platform can report an error with no type after it committed the write (GitHub’s GraphQL mutations do on a timeout).

correspond.idempotency.READ_BACK_SKEW = datetime.timedelta(seconds=300)#

How far before an attempt a message read back may be dated and still be that attempt’s: the platform’s clock and this machine’s differ.

correspond.idempotency.SENT = 'sent'#

The write went out; the record keeps its result.

correspond.idempotency.attempted_at(record)[source]#

When record’s key was claimed.

Return type:

datetime

correspond.idempotency.check_key(key)[source]#

key stripped, or ValueError for one that is not a non-blank string of at most MAX_KEY_LENGTH characters.

>>> check_key(" case-12/reply-3 ")
'case-12/reply-3'
Return type:

str

correspond.idempotency.claimed(key, conversation, *, fingerprint, at)[source]#

The record of key claimed at at for a real write to conversation.

Return type:

dict[str, Any]

correspond.idempotency.fingerprint(conversation, draft)[source]#

What a key is bound to: the conversation and everything the draft says, as SHA-256 hex.

Return type:

str

correspond.idempotency.matching_message(messages, draft, *, since)[source]#

The latest of messages written by correspond’s own account with draft’s text, at or after since less READ_BACK_SKEW.

With draft.notify, the text as a mention writer posts it (the mentions it lacked added, correspond.notify.with_mentions()) matches too.

Return type:

Message | None

correspond.idempotency.now()[source]#

The moment a key is claimed (UTC).

Return type:

datetime

correspond.idempotency.settled(record, state, result)[source]#

record moved to state (SENT or FAILED), with the write’s result.

Return type:

dict[str, Any]