DEV Community

xiaoxu
xiaoxu

Posted on

Preventing Duplicate Posts After an Ambiguous Publishing API Failure

Preventing Duplicate Posts After an Ambiguous Publishing API Failure

Why this matters

An HTTP request can fail without telling you whether the operation failed.

Imagine an automated publisher sends POST /articles. The remote service creates the article, but the response is lost during a timeout. Locally, all you see is an exception. If the job treats that exception as proof that nothing happened and retries the same create request, it may publish the article twice.

That distinction matters most in unattended workflows. A person can inspect a dashboard before clicking again. A daily automation needs an explicit rule it can enforce without guessing:

An unknown outcome is not a failed outcome, and it must not be retried as a new create operation.

This article shows the small state machine I used to encode that rule in a TypeScript publishing pipeline.

What I built or tested

I tested a publisher that stores one publication record per article slug and platform. The record can be draft, published, failed, or unknown, and it can also retain a remote article ID and a hash of the outgoing content.

The focused question was: how should the publisher behave when a create request returns no trustworthy result?

The implementation has three relevant safeguards:

  1. Persist unknown before sending a create request.
  2. Replace it with the returned ID and URL only after a successful response.
  3. Refuse another automatic create while the stored result remains unknown.

This is not a claim that the transport is exactly-once. It is a way to stop an at-least-once scheduler from turning uncertainty into an avoidable duplicate.

Setup

The example pipeline uses Node.js 22, TypeScript, a persistent publication database, and a DEV.to publisher. The same invariant also applies to other create-style APIs.

The relevant record can be reduced to this shape:

type PublicationStatus = "draft" | "published" | "failed" | "unknown";

interface PlatformPublication {
  status: PublicationStatus;
  remoteId?: string;
  url?: string;
  contentHash?: string;
  error?: string;
}
Enter fullscreen mode Exit fullscreen mode

The content hash is useful for diagnostics: it records which payload was in flight. It is not an idempotency key unless the remote API accepts and enforces it.

Step-by-step walkthrough

1. Check the previous platform state

Before doing any network write, the publisher loads the record for the article slug. A known remote ID means the next operation can be an update. An unknown status means the pipeline stops and asks for reconciliation.

const existing = record.platforms.devto;

if (existing?.status === "unknown") {
  throw new Error(
    "Create result is unknown; reconcile the remote platform before retrying.",
  );
}
Enter fullscreen mode Exit fullscreen mode

This guard is intentionally checked before choosing between create and update. It prevents a scheduler restart from silently falling back to another POST.

2. Write the intent before the request

For a new article, the pipeline saves an unknown record and the current content hash before contacting the platform:

record.platforms.devto = {
  status: "unknown",
  contentHash: record.sourceHash,
};
await database.savePublishRecord(record);

const result = await publisher.create(input);
Enter fullscreen mode Exit fullscreen mode

This ordering closes an important local crash window. If the process stops after the network write begins, the next run sees uncertainty instead of an empty record.

3. Commit the success result immediately

When the create request returns successfully, the publisher stores the platform ID, stable URL, final status, and content hash before moving to another platform or task.

record.platforms.devto = {
  status: result.status,
  remoteId: result.remoteId,
  url: result.url,
  contentHash: record.sourceHash,
};
await database.savePublishRecord(record);
Enter fullscreen mode Exit fullscreen mode

The transition is therefore missing -> unknown -> published, not missing -> published.

Mermaid diagram 1

The sequence makes the safety boundary visible: only a confirmed response advances the local state to published.

4. Classify errors conservatively

The pipeline classifies a missing HTTP status, rate limit, or server error as unknown. A definite client-side rejection can be recorded as failed because the server has explicitly rejected the request.

Signal Stored state Automatic create retry?
Successful response with article ID published or draft No create; future changes update by ID
No status, timeout, 429, or 5xx unknown No
Definite non-rate-limit 4xx failed Only after the input or configuration is fixed

The table is deliberately conservative. Some APIs provide stronger idempotency guarantees, but the client should not invent them.

What went wrong

The tempting implementation is to wrap every network call in exponential backoff. That is appropriate for idempotent reads and often for idempotent updates. It is dangerous for a create request when the API does not accept a client-controlled idempotency key.

The project contains a generic retry helper, but the JSON request function used by the publishers performs a single fetch with a 30-second timeout. In other words, the create path does not blindly use the generic retry helper. That separation is intentional and worth checking in code reviews: the mere presence of a retry utility does not mean every operation is safe to retry.

There is another subtle boundary after creation. DEV.to may require a follow-up read to resolve the stable public URL. If that lookup fails, the create itself has already returned an article ID. The publisher keeps the create response URL instead of downgrading the successful publication to unknown. A read-side enrichment failure must not erase a confirmed write.

I did not force a real DEV.to timeout to demonstrate this failure. Doing so against a production create endpoint would itself risk the duplicate this design is intended to prevent. The failure was verified through source inspection, state-transition tests, and a normal end-to-end publication after the gates passed.

Fix or mitigation

The immediate mitigation is to treat unknown as a terminal state for unattended creation. Recovery becomes an explicit reconciliation procedure:

  1. Search the remote platform by the expected title, slug, author, publication time, or stored content hash evidence.
  2. If the article exists, attach its remote ID and URL to the local record and mark it published or draft.
  3. If the platform can prove that no article was created, reset the local state and allow one new create.
  4. If the result is still uncertain, leave it unknown and escalate. Do not guess.

For APIs that support an idempotency-key header, use a stable operation key and persist it with the record. That can make a retry safe, but only when the server documents the key's scope, retention window, and response behavior.

For the daily automation around this publisher, I added a second layer: one selected topic per date, a maximum of one public publish per run, and a terminal daily state. Even if the scheduler wakes again on the same day, it returns the recorded result instead of selecting another article.

Trade-offs

This design prefers duplicate prevention over automatic recovery.

  • An unknown result requires manual or API-assisted reconciliation, so a transient outage can delay the day's article.
  • Persisting before and after the network request adds database writes and makes state storage part of the critical path.
  • A content hash helps identify the attempted payload but cannot prove remote uniqueness by itself.
  • Conservative error classification can stop on some server errors where the write definitely failed, because the client does not have enough evidence to know that.

The benefit is a much safer failure mode: the automation pauses visibly instead of producing a second public artifact.

How I verified it

I used four checks:

  1. Source trace: confirmed that a new DEV.to create saves unknown before the request and saves the remote result immediately afterward.
  2. Failure trace: confirmed that transport errors, 429, and server errors persist as unknown, while the publication loop rejects any existing unknown result before another create.
  3. Automated tests: ran the TypeScript check and the repository test suite, including the invariant that an unknown publication outcome selects reconciliation instead of another write.
  4. Publisher dry run: rendered the article, processed its local Mermaid diagram, and generated the publication payload without a network write before the single authorized public run.

One limitation remains in the test suite: the direct unit test for the unknown-to-reconcile transition currently exercises the Hashnode action selector. The DEV.to protection is enforced in the shared publication loop and was inspected there, but a focused DEV.to regression test would make that guarantee more obvious.

Conclusion

The important state in a publisher is not only “published” or “failed.” It is also “I sent a request, but I cannot prove what the remote system did.”

Persist that uncertainty before the create call, refuse blind retries, and reconcile it using remote evidence. This small state-machine change turns an ambiguous API failure from a duplicate-post incident into a controlled pause—exactly the behavior an unattended daily publisher needs.

AI assistance disclosure

AI assisted with outlining and drafting. All implementation claims were checked against the repository, and the described commands and publication path were verified in the local project before release.

Top comments (0)