Skip to content

fix(engagement): wait for Threads reply containers and handle failed sends - #202

Merged
adiologydev merged 9 commits into
mainfrom
thread-reply-post-failure
Nov 7, 2026
Merged

adiologydev merged 9 commits into
mainfrom
thread-reply-post-failure

Conversation

@adiologydev

@adiologydev adiologydev commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #201.

A Threads reply read its container status once, right after it created the container. Any IN_PROGRESS response failed the reply. Threads replies now go through the SendReply job, which waits for the container without holding a web or queue worker, then publishes once.

An audit of that failure found the same problems in other parts of the app, so this PR also fixes those.

Problem Where Fix
Single status check Threads replies SendReply makes one check per run and schedules a follow-up every 3s, up to 20 times. It never sleeps, because the default is one queue worker. The container ID travels in the job, so a retry cannot create a second container
Queued replies were invisible Inbox The pane polls while a reply is sending. A failed job shows the stored reason and restores the draft. The conversation is marked replied only after the job sends
Raw cURL text shown to users Replies and actions on 6 platforms, X DMs, 8 publish connectors, job failures ConnectionFailure logs the raw text and returns a plain message. Publish attempts keep the raw text in response_excerpt
422 treated as success, and rejections silenced with empty catch blocks Inboxes, composer, post page, share dialog, mentions settle() in lib/http/errors.ts returns { ok, value }; failureToasts() tells the user why. Callers branch on the result, and send functions return whether they sent
Failed mention save blanked the page Composer, inbox reply box Shared useSaveMention hook
Composer errors never shown submit-bar.tsx 422 handlers moved to onError; conflicts return 409
Publish went ahead after a failed save submit-bar.tsx, use-autosave.ts flush returns whether the save worked, and the composer stops if not
Unhandled rejections, no feedback Messages, post page actions, share dialog Each one shows the reason and undoes or skips its state change
Rate-limited replies failed SendReply A rate-limit release counted as an attempt. The job now retries for 10 minutes and stops after 3 real errors
A save error after posting retried the job SendReply The job fails at once with "may have posted", so it cannot post twice
Draft restored into the wrong conversation Inbox Restored only into its own conversation; text and attachments both come back
Unclear mention name error Mentions Says which characters a name may use
Failed bubbles piled up Both inboxes A retry replaces the earlier failed bubble; in Engagement it also deletes our stored failed attempt
Toast covered Reply All pages Toaster moved to top-center
Threads' reason hidden Threads publishing Requests and shows error_message
Test code in the production bundle pages/**/*.tsx glob Moved .tsx tests out of pages/

Behaviour changes to review

  • POST /posts/{post}/publish and POST /posts/{post}/queue now return 409, not 422, for conflicts: no accounts, blocked accounts, no open slot. useHttp passes a 422 only to onError, which cannot read blocked. submit-bar.tsx is the only frontend caller. Real validation stays 422.
  • Toasts moved from bottom-right to top-center across the app.
  • Publish failures now stop when the save before them fails. Before, stale content could go live.
  • Network errors now read "Could not reach the platform." The old raw text stays in the log.
  • A timed-out Threads publish tells the user to check the post before they send it again, because that reply may be live.
  • Threads replies are now asynchronous. POST /engagement/{reply}/reply returns a sending row, so Threads replies need a running queue worker.
  • New column: post_target_replies.send_error (nullable text) holds the reason a queued reply failed.
  • A new reply deletes our earlier failed attempts at the same reply. They never reached the platform.
  • Dependencies: lockfile-only security bumps for phpunit, pest, and Symfony 8.1 components (mime, filesystem, http-foundation, http-kernel, routing, serializer, yaml), so composer-audit passes.

Known limits

  • With no queue worker running, a Threads reply stays on "Sending…". After 5 minutes the inbox stops polling and says the reply is still sending.
  • If the job gives up after about a minute, the reason says Threads is still processing. Nothing is published in that case.
  • A partial Meta DM (attachment sent, text failed) still keeps the attachment in the draft, so a retry fails validation. This is not new, and a fix needs a server and client contract change.
  • Meta and Bluesky DM connectors have no ConnectionException catch, so a network failure there is a 500.
  • Instagram publishing does not request a failure reason from Meta.
  • threads_read_replies is not in the requested Threads scopes. Meta's docs did not confirm whether fetching replies needs it.
  • The mention delete failure branch has no direct test.
  • The failed-bubble reason is small red text on the green bubble: readable, but low contrast. That styling was already there.

Testing

  • Pest: 2131 passed. Vitest: 962 passed.
  • tsc, oxlint, oxfmt, Pint, and Larastan: clean. All CI checks pass.
  • Copilot's 5 review comments are fixed, each with a test that failed first.
  • End-to-end in a real browser against the local app, with outbound HTTPS blocked (Playwright, 14 of 14 passed, no page errors or unhandled rejections):
    • Threads reply queued, then sent: the poll picks it up and marks the conversation replied.
    • Threads job failure: reason in a toast and in the bubble (also after a reload), draft restored, one failed bubble.
    • A failure that arrives while another conversation is open does not touch that box.
    • X reply, like, and delete failures show a plain message and undo; archive works.
    • Messages: repeated failed sends keep the draft and leave one failed bubble.
    • Post page reschedule, share link create and revoke, unschedule, and retry work.
    • Composer: an invalid mention name shows the reason, and the page stays usable.
  • Not tested: a live Threads account (the success path sets the row to sent in the database, so it does not reach the Threads API), real request timeouts, and the composer 409 paths in a browser (the UI blocks them first; covered by feature and unit tests).

…sends

A Threads reply read its container status once, right after creating it,
so any IN_PROGRESS response failed the reply. It now polls the same
container for up to 12 seconds, with timeouts that fit the Octane limit.

The audit of that failure found the same problems elsewhere:

- Raw cURL text reached users through reply, DM, and publish errors. It now
  goes to the log and attempt rows, and users see a plain message.
- useHttp sends a 422 only to onError and resolves empty, so callers treated
  it as success. Drafts were lost, and a failed mention save blanked the page.
- submit-bar could never show its 422 errors. Publish and queue conflicts now
  return 409, and the composer stops when the save before a publish fails.
- Failed requests in Messages, post actions, and the share dialog left
  unhandled rejections and no feedback.
- Toasts covered the Reply button, so the toaster moved to top-center.
- Test files under pages/ were built into the production bundle.

Fixes #201
…ries

Fixes the composer-audit failure from CVE-2026-119193 (phpunit) and
CVE-2026-45070 (symfony/mime). Pest moves too, because v5.3.3 conflicts with
phpunit above 13.4.3.
The bounded poll held an Octane worker while Threads processed the container,
and the 12 second limit was shorter than Meta's guidance.

Threads replies now go through SendReply. The job makes one status check per
run and schedules a follow-up when the container is still processing, so the
wait holds no worker at all. This matters because the default is a single
queue worker, and a sleeping job would delay scheduled posts. The container id
travels in the job, so a retry cannot create a second container.

The inbox polls while a reply is sending. When the job fails, it shows the
stored reason and restores the draft. A queued reply is marked replied only
after the job sends it.
…them

SendReply shares a 10-per-minute limit per platform. A rate-limit release
still counts as an attempt, so with tries = 3 a reply that waited for its turn
failed after three releases. Threads text replies now use this job, so a busy
minute would fail them.

The job now retries for 10 minutes and still stops after 3 real exceptions.
…tops

A failed queued reply stayed in the thread, so each retry added another
"Failed to send" bubble. A new reply now deletes our failed attempts at the
same reply, which never reached the platform, and the inbox drops their
bubbles.

The inbox stopped polling a sending reply after 5 minutes without a word. It
now tells the user the reply is still sending.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Failed-reply recovery can restore drafts into the wrong conversation, while retries after Threads publication or follow-up dispatch failures can create duplicate replies.

5 open issues
What changed in this PR

Addresses #201 by moving Threads replies into queued container checks, with inbox polling and failure recovery. It also improves error handling across publishing, messaging, mentions, and sharing.

Changes:

  • Adds asynchronous Threads reply processing and persisted send errors.
  • Preserves drafts on failed requests, surfaces actionable errors, and stops submission when saving fails.
  • Adds regression coverage, moves reply component tests outside the page bundle, and updates locked dependencies.
File Description
tests/​Unit/​Support/​ConnectionFailureTest.php Tests safe connection-error messages and logging.
tests/​Unit/​Engagement/​ThreadsEngagementConnectorTest.php Covers processing containers and publication failures.
tests/​Feature/​Publishing/​ThreadsConnectorTest.php Tests provider failure reasons and status fields.
tests/​Feature/​Publishing/​PublishEndpointTest.php Expects 409 responses for publish conflicts.
tests/​Feature/​Posts/​QueueApiTest.php Expects 409 responses for unavailable slots.
tests/​Feature/​Engagement/​ThreadsReplyInBackgroundTest.php Covers queued replies, follow-up checks, and failures.
resources/​js/​pages/​messages/​index.tsx Handles validation failures and replaces failed bubbles.
resources/​js/​pages/​messages/​helpers.ts Re-exports shared failure helpers.
resources/​js/​pages/​messages/​components/​quick-message-box.tsx Catches rejected sends while retaining drafts.
resources/​js/​pages/​engagement/​types.ts Adds the persisted send-error field.
resources/​js/​pages/​engagement/​index.tsx Polls queued replies and handles completion or failure.
resources/​js/​pages/​engagement/​helpers.ts Re-exports shared failure helpers.
resources/​js/​pages/​engagement/​components/​reply-thread.test.ts Updates fixtures with send errors.
resources/​js/​pages/​engagement/​components/​quick-reply-box.tsx Preserves failed drafts and shares mention-saving logic.
resources/​js/​lib/​inbox/​helpers.ts Adds failed-bubble filtering and shared error exports.
resources/​js/​lib/​inbox/​__tests__/​quick-reply-box.test.tsx Relocates tests and covers rejected sends.
resources/​js/​lib/​inbox/​__tests__/​messages-right-pane.test.tsx Tests message failures and draft preservation.
resources/​js/​lib/​inbox/​__tests__/​helpers.test.ts Tests failure filtering and validation messages.
resources/​js/​lib/​inbox/​__tests__/​engagement-right-pane.test.tsx Tests polling, draft recovery, and completion handling.
resources/​js/​lib/​http/​errors.ts Centralizes request-error messages and response guards.
resources/​js/​lib/​http/​__tests__/​errors.test.ts Tests the empty-response guard.
resources/​js/​hooks/​compose/​use-save-mention.ts Shares mention saving with explicit failure handling.
resources/​js/​hooks/​compose/​use-publish-status.ts Reports retry failures without losing the snapshot.
resources/​js/​hooks/​compose/​use-autosave.ts Returns save success from draft flushing.
resources/​js/​hooks/​compose/​__tests__/​use-save-mention.test.tsx Tests mention-save success and rejection.
resources/​js/​hooks/​compose/​__tests__/​use-publish-status.test.tsx Tests failed publish retries.
resources/​js/​hooks/​compose/​__tests__/​autosave.test.ts Tests flush success and failure results.
resources/​js/​components/​ui/​sonner.tsx Moves global toasts to top-center.
resources/​js/​components/​posts/​share-dialog.tsx Handles share loading, creation, and revocation failures.
resources/​js/​components/​posts/​post-page-actions.tsx Reports scheduling and retry failures.
resources/​js/​components/​posts/​__tests__/​share-dialog.test.tsx Tests share-dialog failure feedback.
resources/​js/​components/​posts/​__tests__/​post-page-actions.test.tsx Tests scheduling and retry error handling.
resources/​js/​components/​compose/​submit-bar.tsx Blocks submission after failed saves and handles conflicts.
resources/​js/​components/​compose/​mention-picker.tsx Keeps the picker open after failed saves.
resources/​js/​components/​compose/​editor-body.tsx Exposes an empty-editor check for draft recovery.
resources/​js/​components/​compose/​composer.tsx Shares mention saving and catches deletion failures.
resources/​js/​components/​compose/​__tests__/​submit-bar-failures.test.tsx Tests save guards and submission errors.
resources/​js/​components/​compose/​__tests__/​mention-picker.test.tsx Tests picker behavior after save rejection.
database/​migrations/​2026_11_02_184653_add_send_error_to_post_target_replies.php Adds nullable storage for reply failure reasons.
composer.lock Updates Symfony Mime, Pest, and PHPUnit locks.
app/​Support/​ReplyListItem.php Includes send errors in inbox responses.
app/​Support/​ConnectionFailure.php Logs transport details and returns safe messages.
app/​Services/​Publishing/​Connectors/​XConnector.php Sanitizes connection failures while retaining diagnostics.
app/​Services/​Publishing/​Connectors/​ThreadsConnector.php Sanitizes transport errors and requests provider reasons.
app/​Services/​Publishing/​Connectors/​LinkedInConnector.php Sanitizes connection failures while retaining diagnostics.
app/​Services/​Publishing/​Connectors/​InstagramConnector.php Sanitizes connection failures while retaining diagnostics.
app/​Services/​Publishing/​Connectors/​FacebookConnector.php Sanitizes connection failures across publishing paths.
app/​Services/​Publishing/​Connectors/​DiscordPublishConnector.php Sanitizes connection failures while retaining diagnostics.
app/​Services/​Publishing/​Connectors/​BlueskyPublishConnector.php Sanitizes publishing and repost connection failures.
app/​Services/​Messaging/​Connectors/​XDirectMessageConnector.php Returns safe direct-message connection errors.
app/​Services/​Engagement/​Contracts/​PublishesReplyContainers.php Defines resumable reply-container publication.
app/​Services/​Engagement/​Connectors/​XEngagementConnector.php Sanitizes reply and action connection errors.
app/​Services/​Engagement/​Connectors/​ThreadsEngagementConnector.php Supports container continuation and uncertain-send warnings.
app/​Services/​Engagement/​Connectors/​LinkedInEngagementConnector.php Sanitizes reply and action connection errors.
app/​Services/​Engagement/​Connectors/​InstagramEngagementConnector.php Sanitizes reply and action connection errors.
app/​Services/​Engagement/​Connectors/​FacebookEngagementConnector.php Sanitizes reply and action connection errors.
app/​Services/​Engagement/​Connectors/​BlueskyEngagementConnector.php Sanitizes reply and action connection errors.
app/​Models/​PostTargetReply.php Makes send errors assignable and documents their type.
app/​Jobs/​SendReply.php Schedules container checks and stores failure reasons.
app/​Jobs/​PublishPostTarget.php Sanitizes token-refresh and terminal job errors.
app/​Jobs/​DeletePostTarget.php Stores safe terminal connection-error messages.
app/​Http/​Controllers/​Posts/​PublishController.php Returns 409 for publish conflicts.
app/​Http/​Controllers/​Posts/​PostQueueController.php Returns 409 for unavailable queue slots.
app/​Http/​Controllers/​Engagement/​EngagementController.php Queues Threads replies and removes failed attempts.
app/​Enums/​Platform.php Identifies Threads replies as background sends.
app/​Dto/​Engagement/​ReplyPostResult.php Carries processing container IDs between checks.

Review effort: Balanced

Comment thread app/Jobs/SendReply.php
Comment on lines +115 to +117
$result = $this->containerId !== null && $connector instanceof PublishesReplyContainers
? $connector->publishContainer($account, $this->containerId, $credentials)
: $connector->postReply($account, $parent, $this->text, $credentials, $media);
Comment thread resources/js/pages/engagement/index.tsx Outdated
Comment on lines +383 to +393
queuedReplies.current.delete(reply.id);
if (reply.send_status === 'sent') {
onResponded(queued.parentId);
} else {
toast.error(reply.send_error ?? 'Could not send the reply.');
if (replyEditorRef?.current?.isEmpty()) {
replyEditorRef.current.insertText(queued.draft);
}
}
}
}, [thread, replyEditorRef, onResponded]);
try {
$status = $this->http->get(self::BASE_URL.'/'.$containerId, [
'fields' => 'status',
$response = $this->http->get(self::BASE_URL.'/'.$containerId, [
Comment on lines +379 to +381
const queued = queuedReplies.current.get(reply.id);
if (queued === undefined || reply.send_status === 'sending') {
continue;
Comment thread resources/js/pages/engagement/index.tsx Outdated
Comment on lines +424 to +427
queuedReplies.current.set(reply.id, {
draft: text,
parentId: selected.id,
});
- A save error after the platform accepted the reply no longer retries the
  job, which would post it twice. The job fails at once and tells the user
  the reply may have posted.
- A failed draft is restored only into its own conversation. A stale poll
  could put it into the box of the conversation now open.
- Restored drafts keep their attachments, through a restore handle on the
  reply box.
- The failed bubble shows the stored reason, so it is visible after a reload.
- The Threads status check has short timeouts, so a hung request hands the
  queue worker back.
Fixes the composer-audit failure from new advisories in symfony/filesystem,
http-foundation, http-kernel, routing, serializer, and yaml (8.1.9 to 8.1.11).
…tches

`useHttp` calls a request's failure callback and then still rejects, and it
resolves with nothing on a 422. Callers had silenced that rejection with
empty `catch` blocks and `.catch(() => {})` all over the inbox, composer, and
post pages.

`settle()` in `lib/http/errors.ts` now waits for a request and returns
`{ ok, value }`. The failure callbacks tell the user what went wrong, and each
caller branches on the result:

- Send functions return whether they sent, so the reply and message boxes
  keep the draft without a try/catch.
- Like, delete, archive, mark-read, share, retry, reschedule, and mention
  save/delete restore or skip their state change when the request failed.
- Thread loading goes through one `loadThread()` that returns null when the
  thread cannot be loaded, and ignores answers that arrive after the
  selection moved on.
An invalid name showed Laravel's default "The name field format is invalid.",
which does not say what to change. Found in an end-to-end run of the composer.
@adiologydev
adiologydev merged commit ed757db into main Nov 7, 2026
6 checks passed
@adiologydev
adiologydev deleted the thread-reply-post-failure branch November 7, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Threads: replying to a post comment fails while the reply container is still processing

2 participants