Skip to content

Commit ed757db

Browse files
authored
fix(engagement): wait for threads reply containers and handle failed sends (#202)
* fix(engagement): wait for Threads reply containers and handle failed 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 * chore(deps): bump phpunit, pest, and symfony/mime for security advisories 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. * refactor(engagement): send Threads replies from a background job 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. * fix(engagement): keep rate-limited replies queued instead of failing 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. * fix(engagement): replace failed reply attempts and say when polling stops 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. * fix(engagement): address Copilot review on queued replies - 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. * chore(deps): bump symfony 8.1 components for security advisories 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). * refactor(frontend): handle failed requests with results, not empty catches `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. * fix(mentions): explain which characters a mention name may use 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.
1 parent 5e3df88 commit ed757db

73 files changed

Lines changed: 2969 additions & 640 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/Dto/Engagement/ReplyPostResult.php‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ private function __construct(
1313
public ?string $remoteReplyId = null,
1414
public ?string $remoteCid = null,
1515
public ?string $message = null,
16+
public ?string $processingContainerId = null,
1617
) {}
1718

1819
public static function ok(string $remoteReplyId, ?string $remoteCid = null): self
@@ -40,6 +41,19 @@ public static function failed(?string $message = null): self
4041
return new self(EngagementStatus::Failed, message: $message);
4142
}
4243

44+
/**
45+
* The platform has not finished the reply. Anything that cannot wait for it
46+
* sees an ordinary failure.
47+
*/
48+
public static function processing(string $containerId): self
49+
{
50+
return new self(
51+
EngagementStatus::Failed,
52+
message: 'The platform is still processing the reply. Try again in a moment.',
53+
processingContainerId: $containerId,
54+
);
55+
}
56+
4357
public function isOk(): bool
4458
{
4559
return $this->status->isOk();

‎app/Enums/Platform.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ public function supportsReplyMedia(): bool
156156
};
157157
}
158158

159+
/**
160+
* Threads builds a reply in a container that takes time to finish, so its
161+
* replies always go through SendReply and never wait in a web request.
162+
*/
163+
public function sendsRepliesInBackground(): bool
164+
{
165+
return $this === self::Threads;
166+
}
167+
159168
/**
160169
* Whether this platform's metrics connector returns real post-level metrics.
161170
* Every launched platform, including LinkedIn (Page/Organization accounts
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Exceptions;
6+
7+
use RuntimeException;
8+
use Throwable;
9+
10+
/**
11+
* The platform accepted the reply but we could not record it. A retry would
12+
* post it twice, so the job fails at once and tells the user to check first.
13+
*/
14+
class ReplyMayHavePosted extends RuntimeException
15+
{
16+
public const string USER_MESSAGE = 'The reply may have posted. Check the post before you send it again.';
17+
18+
public function __construct(Throwable $previous)
19+
{
20+
parent::__construct('Reply posted but not recorded: '.$previous->getMessage(), previous: $previous);
21+
}
22+
}

‎app/Http/Controllers/Engagement/EngagementController.php‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,15 @@ public function respond(
357357

358358
$mediaIds = array_values($request->validated('media', []));
359359

360-
if ($mediaIds === []) {
360+
// A new reply replaces our failed attempts at this one; they never reached the platform.
361+
PostTargetReply::withoutGlobalScopes()
362+
->where('post_target_id', $reply->post_target_id)
363+
->where('parent_remote_id', $reply->remote_reply_id)
364+
->where('is_ours', true)
365+
->where('send_status', SendStatus::Failed->value)
366+
->delete();
367+
368+
if ($mediaIds === [] && ! $reply->platform->sendsRepliesInBackground()) {
361369
$result = $registry->for($reply->platform)->postReply($account, $reply, (string) $request->validated('text'), $credentials);
362370

363371
if (! $result->isOk()) {
@@ -393,7 +401,7 @@ public function respond(
393401
return $this->respondedRow($ourRow);
394402
}
395403

396-
// Media path: create the outgoing row in a sending state, then dispatch.
404+
// Background path: create the outgoing row in a sending state, then dispatch.
397405
$ourRow = PostTargetReply::withoutGlobalScopes()->create([
398406
'workspace_id' => $reply->workspace_id,
399407
'post_target_id' => $reply->post_target_id,

‎app/Http/Controllers/Posts/PostQueueController.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public function store(Request $request, Post $post, WorkspaceSubscriptionGate $s
4646
'message' => $request->filled('scheduled_at')
4747
? 'Choose an open slot from your posting queue.'
4848
: 'No open posting slot available. Add posting-schedule slots in settings.',
49-
], 422);
49+
], 409);
5050
}
5151

5252
$post->scheduled_at = $slot;

‎app/Http/Controllers/Posts/PublishController.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public function store(
3030
if ($post->loadMissing('targets')->targets->isEmpty()) {
3131
return response()->json([
3232
'message' => 'Select at least one account to publish.',
33-
], 422);
33+
], 409);
3434
}
3535

3636
if (! $subscriptions->canPublish($workspace)) {
@@ -45,7 +45,7 @@ public function store(
4545
return response()->json([
4646
'message' => "Some accounts can't be published yet.",
4747
'blocked' => $blocked,
48-
], 422);
48+
], 409);
4949
}
5050

5151
$post->forceFill(['status' => PostStatus::Publishing->value])->save();

‎app/Http/Controllers/WorkspaceMentionController.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public function store(Request $request): JsonResponse
3232
'name' => ['required', 'string', 'max:80', 'regex:/^@[A-Za-z0-9_.-]+$/'],
3333
'handles' => ['required', 'array'],
3434
...$handleRules,
35+
], [
36+
'name.regex' => 'A mention name can use only letters A to Z, numbers, dots, dashes, and underscores.',
3537
]);
3638
$handles = [];
3739
foreach ($allowedPlatforms as $platform) {

‎app/Jobs/DeletePostTarget.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Models\PostTarget;
99
use App\Services\Publishing\PublishConnectorRegistry;
1010
use App\Services\Publishing\TokenManager;
11+
use App\Support\ConnectionFailure;
1112
use Illuminate\Contracts\Queue\ShouldQueue;
1213
use Illuminate\Foundation\Queue\Queueable;
1314
use Illuminate\Support\Str;
@@ -54,7 +55,7 @@ public function failed(Throwable $e): void
5455

5556
$target->forceFill([
5657
'status' => PostTargetStatus::Failed->value,
57-
'error_message' => Str::limit($e->getMessage(), 1000),
58+
'error_message' => Str::limit(ConnectionFailure::safeMessage($e), 1000),
5859
'next_attempt_at' => null,
5960
])->save();
6061
}

‎app/Jobs/PublishPostTarget.php‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use App\Services\Publishing\PublishConnectorRegistry;
2626
use App\Services\Publishing\SegmentMediaResolver;
2727
use App\Services\Publishing\TokenManager;
28+
use App\Support\ConnectionFailure;
2829
use App\Support\InstanceSettings;
2930
use Illuminate\Contracts\Queue\ShouldQueue;
3031
use Illuminate\Foundation\Queue\Queueable;
@@ -172,7 +173,7 @@ public function handle(
172173
// A transient token-endpoint failure (429/5xx/timeout) is not a bad
173174
// credential — treat it as a retryable server error so the publish backs
174175
// off and retries instead of flipping the account to needs-attention.
175-
$result = PublishResult::failure(ErrorKind::ServerError, $e->getMessage());
176+
$result = PublishResult::failure(ErrorKind::ServerError, ConnectionFailure::safeMessage($e));
176177
} catch (TokenRefreshException $e) {
177178
$result = PublishResult::failure(ErrorKind::AuthExpired, $e->getMessage());
178179
}
@@ -292,11 +293,13 @@ private function resumePartial(PostTarget $target): void
292293

293294
private function markFailed(PostTarget $target, Throwable $e): void
294295
{
295-
$this->closeOpenAttempt($target, 'failed', Str::limit($e->getMessage(), 1000));
296+
$message = Str::limit(ConnectionFailure::safeMessage($e), 1000);
297+
298+
$this->closeOpenAttempt($target, 'failed', $message);
296299

297300
$target->forceFill([
298301
'status' => PostTargetStatus::Failed->value,
299-
'error_message' => Str::limit($e->getMessage(), 1000),
302+
'error_message' => $message,
300303
'next_attempt_at' => null,
301304
])->save();
302305

‎app/Jobs/SendReply.php‎

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
use App\Enums\Platform;
88
use App\Enums\ReplyStatus;
99
use App\Enums\SendStatus;
10+
use App\Exceptions\ReplyMayHavePosted;
1011
use App\Exceptions\TokenRefreshException;
1112
use App\Models\PostMedia;
1213
use App\Models\PostTargetReply;
1314
use App\Notifications\ReplyFailedNotification;
15+
use App\Services\Engagement\Contracts\PublishesReplyContainers;
1416
use App\Services\Engagement\EngagementConnectorRegistry;
1517
use App\Services\Publishing\TokenManager;
18+
use DateTimeInterface;
1619
use Illuminate\Contracts\Queue\ShouldQueue;
1720
use Illuminate\Foundation\Queue\Queueable;
1821
use Illuminate\Queue\Middleware\RateLimited;
@@ -22,11 +25,22 @@ class SendReply implements ShouldQueue
2225
{
2326
use Queueable;
2427

25-
public int $tries = 3;
28+
public const int CONTAINER_CHECK_DELAY_SECONDS = 3;
29+
30+
public const int MAX_CONTAINER_CHECKS = 20;
31+
32+
/**
33+
* A rate-limit release still counts as an attempt, so a reply waiting for its
34+
* turn would fail after a few releases. Retry by time, and stop on real errors.
35+
*/
36+
public int $maxExceptions = 3;
2637

2738
public int $timeout = 300;
2839

2940
/**
41+
* A processing container comes back as a follow-up job, so the wait never
42+
* holds a queue worker. `$containerId` keeps it from creating a second one.
43+
*
3044
* @param list<string> $mediaIds
3145
*/
3246
public function __construct(
@@ -35,17 +49,25 @@ public function __construct(
3549
public array $mediaIds,
3650
public string $text,
3751
public Platform $platform,
52+
public ?string $containerId = null,
53+
public int $containerChecks = 0,
3854
) {}
3955

4056
/**
4157
* Throttle outbound replies on the same per-platform limiter as the fetch job,
42-
* so a burst of replies can't trip the platform's own rate limits.
58+
* so a burst of replies can't trip the platform's own rate limits. A follow-up
59+
* check is not a new send, and would otherwise spend the limit on status reads.
4360
*
4461
* @return array<int, object>
4562
*/
4663
public function middleware(): array
4764
{
48-
return [new RateLimited("engagement-{$this->platform->value}")];
65+
return $this->containerId === null ? [new RateLimited("engagement-{$this->platform->value}")] : [];
66+
}
67+
68+
public function retryUntil(): DateTimeInterface
69+
{
70+
return now()->addMinutes(10);
4971
}
5072

5173
/**
@@ -79,7 +101,7 @@ public function handle(EngagementConnectorRegistry $registry, TokenManager $toke
79101
}
80102

81103
try {
82-
$credentials = in_array($account->platform, [Platform::X, Platform::Bluesky, Platform::LinkedIn], true)
104+
$credentials = in_array($account->platform, [Platform::X, Platform::Bluesky, Platform::LinkedIn, Platform::Threads], true)
83105
? $tokens->fresh($account)
84106
: [];
85107
} catch (TokenRefreshException) {
@@ -90,7 +112,24 @@ public function handle(EngagementConnectorRegistry $registry, TokenManager $toke
90112

91113
$media = array_values(PostMedia::withoutGlobalScopes()->whereIn('id', $this->mediaIds)->get()->all());
92114

93-
$result = $registry->for($parent->platform)->postReply($account, $parent, $this->text, $credentials, $media);
115+
$connector = $registry->for($parent->platform);
116+
$result = $this->containerId !== null && $connector instanceof PublishesReplyContainers
117+
? $connector->publishContainer($account, $this->containerId, $credentials)
118+
: $connector->postReply($account, $parent, $this->text, $credentials, $media);
119+
120+
if ($result->processingContainerId !== null && $this->containerChecks < self::MAX_CONTAINER_CHECKS) {
121+
self::dispatch(
122+
$this->ourRowId,
123+
$this->parentReplyId,
124+
$this->mediaIds,
125+
$this->text,
126+
$this->platform,
127+
$result->processingContainerId,
128+
$this->containerChecks + 1,
129+
)->delay(self::CONTAINER_CHECK_DELAY_SECONDS);
130+
131+
return;
132+
}
94133

95134
if (! $result->isOk()) {
96135
// The connector's own words — e.g. "LinkedIn comments cannot include
@@ -101,16 +140,21 @@ public function handle(EngagementConnectorRegistry $registry, TokenManager $toke
101140
return;
102141
}
103142

104-
$ourRow->forceFill([
105-
'remote_reply_id' => $result->remoteReplyId,
106-
'remote_cid' => $result->remoteCid,
107-
'send_status' => SendStatus::Sent->value,
108-
])->save();
109-
110-
$parent->forceFill([
111-
'status' => ReplyStatus::Responded->value,
112-
'our_reply_remote_id' => $result->remoteReplyId,
113-
])->save();
143+
// The platform has the reply now: a retry from here would post it twice.
144+
try {
145+
$ourRow->forceFill([
146+
'remote_reply_id' => $result->remoteReplyId,
147+
'remote_cid' => $result->remoteCid,
148+
'send_status' => SendStatus::Sent->value,
149+
])->save();
150+
151+
$parent->forceFill([
152+
'status' => ReplyStatus::Responded->value,
153+
'our_reply_remote_id' => $result->remoteReplyId,
154+
])->save();
155+
} catch (Throwable $e) {
156+
$this->fail(new ReplyMayHavePosted($e));
157+
}
114158
}
115159

116160
/**
@@ -125,13 +169,13 @@ public function failed(Throwable $e): void
125169
// Don't report failure (or re-notify) for a reply that actually went out
126170
// — handle() may have posted successfully and thrown only afterwards.
127171
if ($ourRow !== null && $ourRow->send_status !== SendStatus::Sent) {
128-
$this->failRow($ourRow);
172+
$this->failRow($ourRow, $e instanceof ReplyMayHavePosted ? ReplyMayHavePosted::USER_MESSAGE : null);
129173
}
130174
}
131175

132176
private function failRow(PostTargetReply $ourRow, ?string $reason = null): void
133177
{
134-
$ourRow->forceFill(['send_status' => SendStatus::Failed->value])->save();
178+
$ourRow->forceFill(['send_status' => SendStatus::Failed->value, 'send_error' => $reason])->save();
135179

136180
$author = $ourRow->target?->post()->withoutGlobalScopes()->first()?->author()->first();
137181
$parent = PostTargetReply::withoutGlobalScopes()->find($this->parentReplyId);

0 commit comments

Comments
 (0)