trypost/app/Exceptions/Social/XPublishException.php
Paulo Castellano 7d82cc3f12 fix(x): drop chunked upload chunk size from 5MB to 1MB
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).
2026-05-12 20:02:23 -03:00

90 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class XPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->json();
$rawResponse = $response->body();
$statusCode = $response->status();
$type = data_get($body, 'type', '');
$title = data_get($body, 'title', '');
$detail = data_get($body, 'detail', $title);
$typeSuffix = $type !== '' ? basename((string) $type) : '';
if ($statusCode === 401 || str_contains((string) $type, 'unsupported-authentication')) {
throw new TokenExpiredException(
message: $detail ?: 'Access token has expired or been revoked',
platformErrorCode: $typeSuffix ?: (string) $statusCode,
);
}
if (str_contains((string) $rawResponse, 'invalid URL')) {
return new static(
userMessage: 'Post contains an invalid URL.',
category: ErrorCategory::ContentPolicy,
platformErrorCode: $typeSuffix ?: null,
rawResponse: $rawResponse,
);
}
if (str_contains((string) $rawResponse, 'video longer than 2 minutes')) {
return new static(
userMessage: 'Video exceeds the 2-minute limit.',
category: ErrorCategory::MediaFormat,
platformErrorCode: $typeSuffix ?: null,
rawResponse: $rawResponse,
);
}
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.',
category: ErrorCategory::ServerError,
platformErrorCode: (string) $statusCode,
rawResponse: $rawResponse,
);
}
[$message, $category] = match ($typeSuffix) {
'usage-capped' => ['Usage limit exceeded. Please try again later.', ErrorCategory::RateLimit],
'rate-limit-exceeded' => ['Rate limit exceeded. Please try again later.', ErrorCategory::RateLimit],
'invalid-request' => ['Invalid request. Check your post content.', ErrorCategory::ContentPolicy],
'client-forbidden' => ['App not enrolled or lacks required access.', ErrorCategory::Permission],
'not-authorized-for-resource' => ['Not authorized for this resource.', ErrorCategory::Permission],
'resource-not-found' => ['Resource not found.', ErrorCategory::ContentPolicy],
default => [$detail ?: $title ?: 'An unknown X error occurred.', ErrorCategory::Unknown],
};
return new static(
userMessage: $message,
category: $category,
platformErrorCode: $typeSuffix ?: null,
rawResponse: $rawResponse,
);
}
public function platform(): string
{
return 'x';
}
}