MemorySync

Safe Retries: Idempotent Memory Writes

Timeouts, queue redelivery and agents that repeat a step all send the same write twice. How client_ref and turn replay detection make memory writes safe to retry.

Published on 6 min read

Safe Retries: Idempotent Memory Writes
On this page

A memory write that times out leaves an unknown outcome: the server may have stored it, or it may not. Retry blindly and the same fact can land twice. Skip the retry and it may never land at all.

This post shows how to make MemorySync writes safe to send again. The running example is a support desk whose queue worker writes each support note into the customer's memory. Note 0001 records that the customer's team ships releases every Thursday.

Where duplicate writes come from

Repeated writes usually come from one of these:

  • A timeout after the server acted. add() runs extraction before it returns the stored memory. If your client gives up first, the write can still complete on the server, and your code cannot tell.
  • Queue redelivery. Most queues deliver at least once. A worker that crashes after the write but before it acknowledges the message receives the same message again.
  • An agent repeating a step. An agent that re-plans, or a framework that retries a failed tool call, can run the save step twice with the same content.

In each case the second request is legitimate. The fix is to make sending it again harmless.

Copies cost more than storage. When the fact changes or the customer asks for it to be deleted, every copy has to be found, and a copy worded slightly differently is easy to miss.

Name each record with client_ref

add() accepts client_ref: your own identifier for the record, at most 128 characters. Send the same client_ref again and MemorySync recognises the record instead of storing it a second time. The worker sends note 0001 like this:

JSON
{
"text": "My team ships releases every Thursday.",
"client_ref": "note-0001"
}

The first call answers 201 with the stored memory, id 66438, whose text is "Ships releases every Thursday". The same request sent again answers 200 with this body:

JSON
{
"status": "skipped",
"reason": "already_ingested",
"memory_ids": [66438],
"client_ref": "note-0001",
"candidates_extracted": 0,
"candidates_stored": 0
}

Nothing was extracted or stored the second time, and memory_ids names the memories the first call produced. In the Python SDK the result is an AddSkippedResponse whose reason is already_ingested. An interrupted write becomes safe to retry without first checking whether it landed.

Derive client_ref from your own record id, the way note-0001 comes from note 0001. A value generated per attempt, such as a fresh UUID, differs on every retry and protects nothing. The memory_ids list can also be empty: if the first write stored nothing, there are no ids to return.

A reference names a record, so an edited record needs a new one. If an agent rewrites note 0001, sending the new text under note-0001 comes back as already ingested. Send it as note-0001-v2, and use forget() on the ids the first version produced.

Retrying a conversation turn

add_turn sends one conversation turn to extraction and answers before the facts are stored. It has its own replay check: the same speaker, occurred_at and text for the same user within 30 days is recognised. Here is a user turn from Sam:

Python
receipt = ms.add_turn(
tenant_id="default",
user_id="sam",
text="I ran my first 10K last weekend and finished in 58 minutes.",
role="user",
speaker="sam",
occurred_at="2026-06-16T09:30:00Z",
)

The first call starts extraction:

JSON
{
"memory_id": null,
"status": "processing",
"processing_status": "distilling",
"embed_mode": "none",
"already_exists": false,
"processing_error": null,
"request_id": "106a13b4c78c49f0808924f25c106ecc"
}

The identical turn sent again is recognised as a replay, and nothing is extracted twice:

JSON
{
"memory_id": null,
"status": "ok",
"processing_status": "skipped_replay",
"embed_mode": "none",
"already_exists": true,
"processing_error": null,
"request_id": null
}

The replay key is those three values, so a retry must send the same ones. Take speaker and occurred_at from your stored message, not from the clock when the retry runs, because a retry stamped with a new occurred_at looks like a new turn. The Add Conversation Turn reference gives the rule for errors: on a 429 or a 5xx, keep the turn and send it again.

If your chat stack does not keep a timestamp and a speaker for each message, add them before you add retries; without them, the retry has nothing stable to repeat. Send only user turns, too. An assistant turn stores nothing and comes back with processing_status: "skipped_non_user_turn".

Near-duplicate detection is a filter

Without a client_ref, MemorySync still compares each new fact with what that end user already has. The comparison is by meaning, within the same type and key, so it catches more than exact repeats. It is a filter, not a guarantee.

Exact repeats are the easy case. Send "I am allergic to peanuts." twice and both calls return the same memory, 66437, with no second copy. Both answers are a 201 with an identical body, so the second one does not tell you it was a repeat. With a client_ref, it would.

Rewording is where the filter can miss. A paraphrase may or may not be judged close enough, and a sentence that extraction files under a different key is compared with a different set of memories. For anything you will retry, send a client_ref, and let the filter catch the duplicates nobody planned for.

A retry loop that is safe

Here is the queue worker's write. The reference comes from the note id, rate limits wait as long as the server asks, and only failures that a retry can fix are retried:

Python
import logging
import os
import time
from memorysync import (
AddSkippedResponse,
MemorySyncClient,
MemorySyncError,
RateLimitError,
ServerError,
)
log = logging.getLogger(__name__)
ms = MemorySyncClient(
api_key=os.environ["MEMORYSYNC_API_KEY"],
base_url="https://api.memorysync.io",
)
def save_note(note, attempts: int = 5):
"""Write one support note for its customer. Safe to call again for the same note."""
ref = f"note-{note.id}" # from your record, never generated per attempt
for attempt in range(attempts):
try:
result = ms.add(note.text, end_user_id=note.customer_id, client_ref=ref)
except RateLimitError as err:
time.sleep(max(err.retry_after_seconds, 1.0))
except ServerError:
time.sleep(2 ** attempt)
except MemorySyncError as err:
if err.status_code is not None:
raise # 400, 401, 403, 404: every attempt gets the same answer
time.sleep(2 ** attempt) # timeout or network error: outcome unknown
else:
if isinstance(result, AddSkippedResponse):
log.info("note %s skipped: %s %s", note.id, result.reason, result.memory_ids)
return result
raise RuntimeError(f"note {note.id} not saved after {attempts} attempts")

Acknowledge the queue message after save_note returns. A redelivered message then calls save_note again, gets already_ingested, and moves on. When the loop gives up, move the note to your dead-letter queue instead of dropping it: replaying it later with the same client_ref is just as safe.

The order of the except clauses matters, because RateLimitError and ServerError are both subclasses of MemorySyncError. Errors with a status code other than 429 or 5xx are raised rather than retried. A timeout has no status code, and it is the exact case client_ref exists for.

What makes each retry safe

Write surfaceWhat makes a retry safe
add(), POST /memory/addThe same client_ref. A repeat returns already_ingested with the first write's ids.
bulk_add(), POST /memory/bulk-addA client_ref on each BulkAddItem. A repeated item comes back skipped with already_ingested.
add_turn, POST /v1/memory/add_turnThe same speaker, occurred_at and text within 30 days. A repeat returns skipped_replay.
MCP add_memoryPoll get_event_status for the memory instead of adding it again.
memorysync migrate mem0Each record's Mem0 id is sent as client_ref, so a re-run skips what already moved.
add() without client_refOnly near-duplicate detection, which compares meaning and is not a guarantee.

Next step

Add client_ref to your highest-volume write path first. If you suspect copies are already stored, start with the duplicates guide in the docs.

Share article

Blog

Try it on your own workload

The free tier stores real memories and returns real rankings. One write and one retrieval to start.

Start free. No credit card required.