Repository navigation
fix(engagement): wait for Threads reply containers and handle failed sends - #202
Merged
Merged
Conversation
…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.
There was a problem hiding this comment.
🟡 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
Prevent duplicate Threads replies when retrying failed jobs · New Prevent restoring a failed draft into the wrong conversation · New Use short timeouts for queued-reply status checks · New Show stored errors for failed replies loaded after reload · New Preserve media attachments when restoring failed replies · New
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 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 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 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
Fixes #201.
A Threads reply read its container status once, right after it created the container. Any
IN_PROGRESSresponse failed the reply. Threads replies now go through theSendReplyjob, 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.
SendReplymakes 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 containerConnectionFailurelogs the raw text and returns a plain message. Publish attempts keep the raw text inresponse_excerptcatchblockssettle()inlib/http/errors.tsreturns{ ok, value };failureToasts()tells the user why. Callers branch on the result, and send functions return whether they sentuseSaveMentionhooksubmit-bar.tsxonError; conflicts return 409submit-bar.tsx,use-autosave.tsflushreturns whether the save worked, and the composer stops if notSendReplySendReplyerror_messagepages/**/*.tsxglob.tsxtests out ofpages/Behaviour changes to review
POST /posts/{post}/publishandPOST /posts/{post}/queuenow return 409, not 422, for conflicts: no accounts, blocked accounts, no open slot.useHttppasses a 422 only toonError, which cannot readblocked.submit-bar.tsxis the only frontend caller. Real validation stays 422.POST /engagement/{reply}/replyreturns asendingrow, so Threads replies need a running queue worker.post_target_replies.send_error(nullable text) holds the reason a queued reply failed.phpunit,pest, and Symfony 8.1 components (mime,filesystem,http-foundation,http-kernel,routing,serializer,yaml), socomposer-auditpasses.Known limits
ConnectionExceptioncatch, so a network failure there is a 500.threads_read_repliesis not in the requested Threads scopes. Meta's docs did not confirm whether fetching replies needs it.Testing
tsc, oxlint, oxfmt, Pint, and Larastan: clean. All CI checks pass.