Skip to content

fix(instagram): persist publish state at the publish boundary to prevent duplicates and false failures#1681

Open
giladresisi wants to merge 1 commit into
mainfrom
fix/instagram-publish-boundary
Open

fix(instagram): persist publish state at the publish boundary to prevent duplicates and false failures#1681
giladresisi wants to merge 1 commit into
mainfrom
fix/instagram-publish-boundary

Conversation

@giladresisi

@giladresisi giladresisi commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

User incident

A user reported that a scheduled Instagram post triggered the error email "An error occurred while posting on instagram: Instagram blocked your request" — yet the post was published on Instagram, and retrying created duplicate posts.

The Jul 5 occurrence is fully characterized from Temporal history:

  • postSocial failed with Graph code 4 "Application request limit reached" / subcode 2207051 ("action is blocked") — Instagram's app-level rate limit. Our error mapping surfaces subcode 2207051 as "Instagram blocked your request" (deterministically — every 2207051 response produces that exact email).
  • The activity ran 2m09s before failing — long enough for container creation and status polling — so the failure happened at or after the publish step, consistent with the post being live despite the error.
  • Single execution, attempt: 1, no retries (2207051 is classified bad-body → non-retryable at every layer), so that occurrence's duplicate risk came from the user reasonably retrying a post that was falsely marked failed.
  • The user also reported duplicates for posts on June 24–27. ⚠️ Temporal history for those runs is no longer retained, so whether those duplicates came from user retries or from automatic activity retries (e.g. the 10-minute activity timeout on long video processing — which the repro below shows produces silent duplicates) is undetermined.

Root cause

postSocial persists nothing until the entire activity completes. The Instagram flow is multi-step (create container → poll status → media_publish → fetch permalink), so any failure after media_publish:

  1. marks a live post as ERROR and emails a false failure (inviting a manual duplicate), and
  2. leaves Temporal retries free to publish again from scratch (automatic duplicates, with a green workflow and a success email — invisible in Postiz).

Reproduction (unpatched code, real Instagram account)

⚠️ The production trigger (the code 4 rate limit seen in the Jul 5 Temporal history) cannot be triggered on demand, so both repros use synthetic failures at the same boundaries:

  • False failure: force the permalink fetch to fail right after a successful publish → post live on Instagram, but Postiz marks it ERROR and sends the error email.
  • Silent duplicates: crash the activity right after a successful publish → the Temporal retry re-publishes → two identical Instagram posts, green workflow, success email, no trace in Postiz.

Fix

  • Publish-boundary persistence: the provider reports each publish through a new optional progress callback the moment Instagram confirms it; postSocial persists the remote id (updatePost) right there.
  • Retry idempotency guard: on an activity retry, if the persisted releaseId differs from the snapshot the workflow loaded before the activity ran, an earlier attempt already published — return the persisted result instead of publishing again. Comparing against the snapshot (not mere existence) keeps repeatable posts working: they re-run with the same post id and an old releaseId already set.
  • Non-fatal permalink fetch: the post is live when it runs; on any failure — rate limits included — it falls back to the account URL instead of failing the publish. A rate-limited permalink fetch therefore no longer produces any error at all.
  • Step-labeled Graph calls (instagram-create-media, instagram-media-status, instagram-media-publish, instagram-create-carousel, instagram-permalink): every future failure names the exact call, so publish-step vs post-publish failures are distinguishable at a glance.
  • LinkedIn providers: thread the new optional progress parameter through post() (they had a trailing parameter in the same position).

Post-fix validation (same scenarios, patched code)

  • Permalink failure: post published, shown as published in Postiz with the account URL as release URL, one success email, no error email, green workflow.
  • Crash after publish: exactly one Instagram post — the retried attempt hit the guard (attempt: 2 visible in Temporal with the simulated failure as lastFailure), skipped publishing, and completed successfully.

Remaining gap (out of scope for this PR)

If Instagram returns an error on the media_publish call itself but processes the publish anyway (which Meta occasionally does under rate limiting), the post is still marked failed — this is undetectable from the publish response. After this fix, that is the only case where a failure email can coexist with a live post; users seeing "Instagram blocked your request" during heavy posting should double-check Instagram before retrying.

Possible follow-up to also remove the error in this edge case — verify-then-decide: when media_publish fails with a rate-limit/block-type error, query the account's recent media (GET /{ig-user-id}/media?fields=id,caption,timestamp,permalink&limit=10) before failing the post:

  • media found within the publish window (timestamp ≥ publish start, caption match where present) → Instagram processed it: report success with the real media id through the same progress callback — no error shown, no duplicate risk;
  • no match → the block was real: fail as today;
  • verification call itself fails (same rate-limited window) → fail as today, never regress.

Blanket-treating publish-call rate limits as success is NOT an option: in the common case the post really wasn't published, and a false "published" (silent publish loss) is worse than a false "failed". The timestamp window must be tight so a repeatable post's previous cycle (identical caption) can't false-match, and stories are excluded initially (different edge, murkier matching).

Known limitation

Multi-media stories publish in a loop with a per-story checkpoint: a retry after a mid-loop crash returns the already-published stories without duplicating them, but does not publish the remainder — biased toward never duplicating.

🤖 Generated with Claude Code

@postiz-contribution postiz-contribution Bot added the contribution:approved Approved contributor label Jul 6, 2026
@postiz-contribution

Copy link
Copy Markdown

Contribution-checker quality warning
Heuristic score: 0/100 (low). This is a non-blocking warning surfaced by the project's quality settings.

Heuristics that flagged:

  • Wall-of-text PR body: 4404 chars (>2500)
  • Excessive emojis in body: 3 emojis (>2)
  • Excessive inline code references: 20 inline refs (>3)
  • PR doesn't use the repo's PR template: 5 required checkbox items missing (max 1, match ≥80%)
  • Body adds too many extra headers beyond the template: 6 extra headers (>1)
  • AI watermark phrase: Matched: "Generated with Claude Code"
  • Commit message too long: Longest: 2993 chars
  • Excessive added comments: 17 added comment lines (ratio 0.14)

If this is a genuine contribution, please add detail to your PR description and tighten the diff scope before reviewers look at it.

@postiz-agent

postiz-agent Bot commented Jul 6, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Comment on lines +761 to 771
releaseURL: fallbackUrl,
status: 'success',
});

lastPermalink = await this.getPermalink(
type,
mediaId,
userToken || accessToken,
fallbackUrl
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A partial failure during a multi-story Instagram post causes duplicate stories to be published upon retry due to flawed deduplication logic.
Severity: HIGH

Suggested Fix

The publishing logic should be updated to handle retries of multi-part posts. Instead of re-publishing all items, the loop should check which individual stories have already been published and skip them. This could be achieved by tracking the published state of each media item separately, rather than relying on a single releaseId for the entire batch.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts#L749-L771

Potential issue: When publishing multiple Instagram stories in a single action, the
system updates the post's `releaseId` in the database after each individual story is
successfully posted. If the process is interrupted or fails after some stories have been
published, a subsequent retry will cause duplicate posts. This happens because the
deduplication logic compares the `releaseId` from the database with the `releaseId` of
the post object, which are identical in this partial failure scenario. The check fails
to prevent a re-publish, and the entire sequence of stories is posted again from the
beginning.

Also affects:

  • apps/orchestrator/src/activities/post.activity.ts:229~242

Did we get this right? 👍 / 👎 to inform future reviews.

…ent duplicates and false failures

When an Instagram post fails AFTER media_publish succeeded, the post is
marked ERROR and the user emailed "we couldn't post" — even though the
post is live — and nothing stops the publish from happening again: a
Temporal retry of the activity (or the user, prompted by the false
failure) publishes a duplicate. postSocial gave the platform no memory
of a publish until the entire activity completed.

The production occurrence is fully visible in Temporal: postSocial
failed with Instagram's "Application request limit reached" (code 4,
"blocked" subcode 2207051) after ~2 minutes — at or after the publish
step — while the post went live. That error cannot be triggered on
demand, so both failure boundaries were reproduced on unpatched code
with synthetic failures and a real Instagram account:
- a failing permalink fetch right after a successful publish left the
  post live on Instagram while Postiz marked it ERROR and emailed an
  error — the false-failure variant
- a crash after a successful publish (simulating the activity dying or
  timing out) made the Temporal retry publish a second, identical
  Instagram post, with a green workflow and a success email — invisible
  duplicates

Changes:
- provider post(): report each publish to a new optional progress
  callback the moment Instagram confirms it, before any later step can
  fail
- postSocial: persist the remote id via that callback (updatePost), and
  skip publishing on retry when the persisted releaseId differs from the
  one the workflow loaded before the activity ran — the difference can
  only come from an earlier attempt of this activity. Comparing against
  the workflow snapshot (instead of just checking existence) keeps
  repeatable posts working: they re-run with the same post id and an old
  releaseId already set
- provider post(): the permalink fetch is now non-fatal — the post is
  already live when it runs, so a failure (rate limit included) falls
  back to the account URL instead of failing a successful publish
- provider post(): label every Graph call with a step identifier
  (instagram-create-media, instagram-media-status,
  instagram-media-publish, instagram-create-carousel,
  instagram-permalink) so Temporal failures name the exact call that
  failed
- linkedin providers: thread the new optional progress parameter through
  post() (they had a trailing options parameter in the same position)

Validated on the patched code with the same two scenarios:
- permalink failure: post published, no error email, post shown as
  published with the account URL as the release URL
- crash after publish: exactly one Instagram post; the retried attempt
  hit the guard, skipped publishing and completed the workflow
  successfully

Remaining gap (out of scope): when Instagram returns an error on the
media_publish call itself but processes the publish anyway, the post is
still marked failed — undetectable from the response. A possible future
hardening is verifying against the account's recent media before
failing the post.

Known limitation: multi-media stories publish in a loop; the checkpoint
is per story, so a retry after a mid-loop crash returns the already-
published stories without duplicating them, but does not publish the
remainder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@giladresisi giladresisi force-pushed the fix/instagram-publish-boundary branch from a6a4e43 to 23aa46f Compare July 6, 2026 14:08
@postiz-contribution

Copy link
Copy Markdown

Contribution-checker quality warning
Heuristic score: 0/100 (low). This is a non-blocking warning surfaced by the project's quality settings.

Heuristics that flagged:

  • Wall-of-text PR body: 4404 chars (>2500)
  • Excessive emojis in body: 3 emojis (>2)
  • Excessive inline code references: 20 inline refs (>3)
  • PR doesn't use the repo's PR template: 5 required checkbox items missing (max 1, match ≥80%)
  • Body adds too many extra headers beyond the template: 6 extra headers (>1)
  • AI watermark phrase: Matched: "Generated with Claude Code"
  • Commit message too long: Longest: 3403 chars
  • Excessive added comments: 17 added comment lines (ratio 0.14)

If this is a genuine contribution, please add detail to your PR description and tighten the diff scope before reviewers look at it.

Comment on lines +271 to +281
integration,
async (response) => {
// Persist the remote id the moment the platform confirms the
// publish, so a crash or retry after this point cannot publish a
// duplicate.
await this._postService.updatePost(
response.id,
response.postId,
response.releaseURL
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The deduplication logic for LinkedIn posts is ineffective because the providers don't invoke the progress callback, which can lead to duplicate posts on retry.
Severity: MEDIUM

Suggested Fix

Modify the LinkedIn providers (linkedin.provider.ts and linkedin.page.provider.ts) to invoke the progress callback with the remote post ID after a successful API call. This will persist the ID, allowing the deduplication guard in postSocial to prevent duplicate posts during retries.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: apps/orchestrator/src/activities/post.activity.ts#L268-L281

Potential issue: The deduplication logic in the `postSocial` activity is ineffective for
LinkedIn posts. The guard at `post.activity.ts:230-242` depends on the `progress`
callback to persist the remote post ID. However, the LinkedIn providers
(`linkedin.provider.ts` and `linkedin.page.provider.ts`) accept this callback but never
invoke it. If a post to LinkedIn succeeds but the activity fails before returning, a
retry will bypass the deduplication check, resulting in a duplicate post being created.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contribution:approved Approved contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant