In the automation behind ashikov.ru, prepared social media text follows a separate path after an article is published. A planner determines what can be sent, and an adapter calls the external API. Automatic runs currently only build a plan. Actual sending is restricted to a separately authorized pilot scenario.

The LinkedIn adapter does not automatically retry POST after a timeout. This is a deliberate policy. A rerun must not create a second post when the outcome of the first request is unknown.

This article examines the implemented protocol and its failure tests. It does not describe an incident involving duplicate publications.

A timeout does not tell us whether a post was created

Consider this sequence:

the adapter sends POST
→ the external service creates the post
→ the response is lost
→ the adapter receives a timeout

To the caller, this can look identical to a request that never reached the service. Yet retrying has different consequences. In the first case, it may create a duplicate. In the second, it could complete the original work.

HTTP does not promise idempotency for an arbitrary POST. RFC 9110 advises against automatically retrying non-idempotent requests unless there is a basis for treating their semantics as idempotent or a way to establish that the original action was never applied.1

So “we received no confirmation” and “nothing happened” are different states. Waiting longer before retrying does not resolve that uncertainty.

A log of successful deliveries is not enough

The first obvious solution is to record the post ID after a successful response, then skip completed deliveries on subsequent runs.

That log is necessary, but it leaves two failure windows open.

In the first, the post is created and the response is lost. We have no ID. In the second, the response arrives, but the process exits or fails to persist the confirmation. The post exists, yet the next run still finds nothing in the success log.

In both cases, the rule “no success record means we can send” permits a potential duplicate. The error lies in the interpretation of a missing record.

My adapter therefore stores two separate facts: the intent to attempt a delivery and confirmation of its outcome. Intent alone does not mean that a request was sent or a post was created.

Persist intent before sending the request

The sequence is:

validate the plan, text, and delivery state
→ persist intent in the remote Git repository
→ send one POST
→ receive a successful response with a post ID
→ persist confirmation in the remote Git repository

The intent identifies the specific action, the hash of the prepared text, and the revision used to produce the plan. Writing it to a file on a temporary runner is insufficient: that file may disappear when the job ends. Before POST, the adapter requires the state to be successfully persisted to the remote branch.

In CI, this means committing only the state files and performing a normal push. If intent cannot be persisted, execution never reaches POST. If the push might have succeeded but its confirmation was lost, stopping is still safe: no post has been sent, and the next run will read the remote state again.

After POST, the adapter accepts HTTP 201 together with a post ID in a valid format as confirmation. Only then does it record the result and persist the state to the remote branch again.

On the next run, a confirmed delivery is skipped. If confirmation is absent but intent already exists, the adapter stops for manual investigation. A failed job does not give the next run permission to repeat the external request.

Intent does not turn two actions into a transaction

Writing to Git and creating a post do not share a transaction. Persisting intent and sending POST cannot be atomic. Several states remain possible after adding the marker:

Failure pointWhat the next run knowsDecision
Intent was persisted, but the process stopped before POSTIntent exists without confirmationManual investigation
The post may have been created, but the response was lostIntent exists without confirmationManual investigation
The response arrived, but confirmation was not persisted to GitRemote state contains intent without confirmationRecover confirmation
Confirmation was persistedDelivery is completeSkip without POST

The cost is visible in the first row. The protocol can stop a delivery that never reached the external service. Intent deliberately does not distinguish this case from a lost response.

This is not an exactly-once guarantee. The system favors stopping over a potential duplicate and does not promise to complete every delivery on its own. The protection also depends on preserving state: manually deleting a marker or rolling back the log can remove the evidence that blocks a retry.

The current pilot policy is stricter still: even explicit API rejections, such as 401 or 429, do not trigger automatic retries. This is a policy chosen for a limited rollout, not a claim that every HTTP error has an unknown outcome.

A delivery key is not a content hash

Language versions of the same article need independent states. In my workflow, the key includes the article slug, platform, and language. For example:

example-note:linkedin-ru
example-note:linkedin-en

Confirmation of the Russian publication does not mean the English version was sent. An uncertain outcome for the English delivery must not erase the Russian confirmation.

This separation applies to new linkedin-ru and linkedin-en receipts. For compatibility, an older receipt under the linkedin key blocks both language deliveries: it does not establish that a Russian post was sent.

The content hash serves a different purpose: it verifies that the text being sent is the prepared material from the plan. It does not grant fresh permission to publish. Fixing a typo in text that has already been sent must not create a new delivery.

This separates the identity of an action from the version of its content. Otherwise, any new commit could authorize an already completed operation again.

A rerun must read fresh state

Even a correct marker check is useless if two processes read stale state at the same time and both decide to send.

In the current workflow, actual sends are serialized through one concurrency group. The adapter also uses a file lock within a single environment. That lock does not protect separate runners, so it is insufficient on its own.

Before delivery, CI fetches the latest state branch. The intent is pushed without forcing a history rewrite. A conflict stops the send: the losing process must not proceed to POST based on an outdated decision.

These mechanisms protect the coordinated sending protocol. They do not make the external API part of a Git transaction or prevent the owner from manually creating the same post outside the adapter.

Recovery is a separate operation

If the external service returned a post ID but confirmation could not be persisted to Git, the local result may still help. CI saves the prepared plan, state logs, and execution result as separate recovery artifacts.

The operator checks the external post and merges the confirmed record into the current log. Replacing the entire log with an old file is unsafe: other deliveries may have been recorded since the failure.

Without a post ID, the situation is harder. The operator can look for the post on the platform, but its absence from the visible feed does not prove that the request was never accepted. Until that uncertainty is resolved, the intent remains in place and blocks retries. Resetting the marker and trying again require a separate decision rather than an automatic action when a timer expires.

The retention period for recovery artifacts and the lifetime of the retry block are also different. An expired CI artifact does not remove intent from Git or make a retry safe.

Test for the second POST that must never happen

The adapter tests replace the external HTTP boundary with a controllable test double. This makes it possible to reproduce failures without creating real publications.

The checks verify ordering: by the time the first POST occurs, intent has already been passed to the persistence mechanism. A failure to persist intent leaves the POST count at zero. A timeout or connection loss does not produce a success confirmation, and rerunning does not increase the number of sends.

A separate test simulates a successful response followed by a failure to persist confirmation. It then restores the files as a fresh runner would see them: using only the state that reached remote storage. The next attempt must stop at the existing intent instead of creating a second post.

There is also a successful scenario: after delivery, both the old plan and a recalculated plan treat it as complete. A failure in one delivery does not erase the results of others. A corrupt or missing state file causes a failure rather than being treated as an empty log.

These tests verify the adapter’s decisions at the chosen boundary. They do not prove how the platform itself behaves during a network failure or turn the protocol into a universal guarantee against duplicates.

When automatic retries are appropriate

Blocking retries is not a general rule for every API. If a service supports an idempotency contract, a client can retry with the same key and the service can recognize the original action. The receiving service must honor the key, and the client must respect the limits of that contract. Amazon Builders’ Library discusses this approach in detail.2

A local request hash or a marker in Git does not give the external service that capability. My adapter does not rely on a server-side idempotency key. Its task is narrower: to prevent a blind retry after one of its own unfinished actions.

Before adding a retry, it helps to ask: what evidence makes the retry safe? In this publisher, a timeout provides no such evidence. Persisted intent carries the uncertainty across runs instead of letting each run interpret it as the absence of an action.