From 7d82cc3f12089b9ee8f5bcc1551b6cf183e6d6e5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 12 May 2026 20:02:23 -0300 Subject: [PATCH] fix(x): drop chunked upload chunk size from 5MB to 1MB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production was returning HTTP 413 with empty body on the first APPEND segment of every video upload to X v2 — surfacing to users as 'An unknown X error occurred.' Empty-body 413 is the classic signature of an edge/CDN rejection: the X gateway is denying the request before X's application code sees it. The X v2 reference docs say 'max chunk size: 5MB', but every canonical reference uses 1MB: - X's official Python quickstart: `chunk_size = 1024 * 1024` - X's official JavaScript quickstart: `const chunkSize = 1024 * 1024` - twitter-api-v2 (the most-used Node SDK, used by Postiz et al.): `chunkSize: number = 1024 * 1024` 5MB plus multipart-form overhead apparently exceeds an undocumented edge limit. 1MB is the empirically safe size everyone converges on. Changes: - XPublisher chunked APPEND now uses 1MB chunks. An 8MB video uploads as 8 segments instead of 2; more roundtrips but actually succeeds. - Set explicit Content-Type on each chunk attach (matches the simple upload path in the same file). - XPublishException::fromApiResponse maps HTTP 413 to ErrorCategory::MediaFormat with the message 'Media chunk rejected by X (payload too large).' so we don't surface 413 as 'unknown' if it ever recurs. Test: unit test covering the 413→MediaFormat mapping. Full suite green (1503 passed, 2 skipped). --- app/Exceptions/Social/XPublishException.php | 9 +++++++++ app/Services/Social/XPublisher.php | 9 ++++++--- .../Unit/Exceptions/Social/XPublishExceptionTest.php | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/Exceptions/Social/XPublishException.php b/app/Exceptions/Social/XPublishException.php index 97f8dd18..6cbb1b5f 100644 --- a/app/Exceptions/Social/XPublishException.php +++ b/app/Exceptions/Social/XPublishException.php @@ -47,6 +47,15 @@ public static function fromApiResponse(mixed $response): static ); } + if ($statusCode === 413) { + return new static( + userMessage: 'Media chunk rejected by X (payload too large).', + category: ErrorCategory::MediaFormat, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + if (in_array($statusCode, [500, 502, 503, 504], true)) { return new static( userMessage: 'X server error. Please try again later.', diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 15aa6ec9..ba9ef292 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -205,8 +205,11 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy throw new \Exception('No media_id returned from INIT'); } - // APPEND - Read from temp file in 5MB chunks (memory-safe) - $chunkSize = 5 * 1024 * 1024; + // APPEND - Read from temp file in 1MB chunks. Matches the + // twitter-api-v2 SDK default and X's own quickstart examples; + // larger chunks (we previously used 5MB) trigger 413 at the X + // edge with an empty body, surfacing as "An unknown X error". + $chunkSize = 1024 * 1024; $handle = fopen($tempFile, 'r'); $index = 0; @@ -220,7 +223,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy $appendResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(300) - ->attach('media', $chunk, 'chunk'.$index) + ->attach('media', $chunk, 'chunk'.$index, ['Content-Type' => $mimeType]) ->post("{$this->baseUrl}/media/upload/{$mediaId}/append", [ 'segment_index' => $index, ]); diff --git a/tests/Unit/Exceptions/Social/XPublishExceptionTest.php b/tests/Unit/Exceptions/Social/XPublishExceptionTest.php index 2001af07..978d7413 100644 --- a/tests/Unit/Exceptions/Social/XPublishExceptionTest.php +++ b/tests/Unit/Exceptions/Social/XPublishExceptionTest.php @@ -77,6 +77,18 @@ ->and($exception->userMessage)->toBe('X server error. Please try again later.'); }); +test('HTTP 413 with empty body maps to MediaFormat category', function () { + $response = Http::response('', 413); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::MediaFormat) + ->and($exception->userMessage)->toBe('Media chunk rejected by X (payload too large).') + ->and($exception->platformErrorCode)->toBe('413'); +}); + test('unknown type maps to Unknown category with detail as message', function () { $response = Http::response([ 'type' => 'https://api.x.com/2/problems/some-unknown-problem',