From fdfa62ed9bccec7dba4239581c3bc564c04ea8d3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 31 Mar 2026 19:39:46 -0300 Subject: [PATCH] docs: update error mapping spec to follow Laravel 13 conventions --- .../2026-03-31-social-error-mapping-design.md | 148 ++++++++++++------ 1 file changed, 100 insertions(+), 48 deletions(-) diff --git a/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md b/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md index ed0b2d1d..f5ba3f60 100644 --- a/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md +++ b/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md @@ -6,7 +6,7 @@ ## Problem ## Solution -Create per-platform exception classes inside `app/Exceptions/Social/` that parse API responses and return clear user-facing messages, a categorized error type, and structured log data for Nightwatch. +Create per-platform exception classes inside `app/Exceptions/Social/` that parse API responses and return clear user-facing messages, a categorized error type, and structured context for Nightwatch via Laravel's native `context()` method. ## Architecture @@ -33,6 +33,12 @@ ### Directory Structure ### ErrorCategory Enum ```php + + */ public function context(): array { return [ @@ -71,31 +90,58 @@ ### SocialPublishException (Base Class) ]; } + /** + * Parse an API response and return a platform-specific exception. + */ + abstract public static function fromApiResponse(mixed $response): static; + + /** + * Platform identifier for logging. + */ abstract protected static function platform(): string; } ``` ### Per-Platform Exception (Example: Instagram) -`fromApiResponse` receives the HTTP response and matches error codes to user-friendly messages. Each mapping also assigns a category. +`fromApiResponse` receives the Laravel HTTP response, checks for token errors first, then matches error codes to user-friendly messages with categories. ```php +body(); $json = $response->json() ?? []; - $errorCode = $json['error']['code'] ?? null; - $errorSubcode = $json['error']['error_subcode'] ?? null; + $error = data_get($json, 'error', []); + $errorCode = data_get($error, 'code'); + $errorSubcode = data_get($error, 'error_subcode'); + $errorType = data_get($error, 'type'); - [$message, $category, $code] = match(true) { - // Token errors -> throw TokenExpiredException instead - // (handled before calling this method) + // Token errors throw TokenExpiredException + if ($errorType === 'OAuthException' || $errorCode === 190) { + throw new TokenExpiredException( + data_get($error, 'message', 'Instagram token expired'), + (string) $errorCode, + ); + } - // Media format errors + [$message, $category, $code] = match (true) { + // Media format str_contains($body, '2207026') => ['Unsupported video format', ErrorCategory::MediaFormat, '2207026'], str_contains($body, '2207005') => ['Unsupported image format', ErrorCategory::MediaFormat, '2207005'], str_contains($body, '2207004') => ['Image is too large', ErrorCategory::MediaFormat, '2207004'], @@ -105,7 +151,7 @@ ### Per-Platform Exception (Example: Instagram) str_contains($body, '2207057') => ['Invalid thumbnail offset for video', ErrorCategory::MediaFormat, '2207057'], str_contains($body, '2207023') => ['Unknown media type', ErrorCategory::MediaFormat, '2207023'], - // Upload/processing errors + // Upload/processing str_contains($body, '2207003') => ['Timeout downloading media, please try again', ErrorCategory::ServerError, '2207003'], str_contains($body, '2207020') => ['Media expired, please upload again', ErrorCategory::ServerError, '2207020'], str_contains($body, '2207032') => ['Failed to create media, please try again', ErrorCategory::ServerError, '2207032'], @@ -117,7 +163,9 @@ ### Per-Platform Exception (Example: Instagram) // Content validation str_contains($body, '2207010') => ['Caption is too long', ErrorCategory::ContentPolicy, '2207010'], str_contains($body, '2207028') => ['Carousel validation failed', ErrorCategory::ContentPolicy, '2207028'], - str_contains($body, '2207081') => ["This account doesn't support Trial Reels", ErrorCategory::Permission, '2207081'], + str_contains($body, '2207001') => ['Instagram detected spam, try different content', ErrorCategory::ContentPolicy, '2207001'], + str_contains($body, '2207051') => ['Instagram blocked your request', ErrorCategory::ContentPolicy, '2207051'], + str_contains($body, 'param collaborators is not allowed') => ['Collaborators are not allowed for carousel', ErrorCategory::ContentPolicy, 'collaborators'], // Product tagging str_contains($body, '2207035') => ['Product tag positions not supported for videos', ErrorCategory::ContentPolicy, '2207035'], @@ -131,16 +179,14 @@ ### Per-Platform Exception (Example: Instagram) // Permissions str_contains($body, '2207050') => ['Instagram user is restricted', ErrorCategory::Permission, '2207050'], + str_contains($body, '2207081') => ["This account doesn't support Trial Reels", ErrorCategory::Permission, '2207081'], str_contains($body, 'Not enough permissions to post') => ['Missing permissions, please reconnect your account', ErrorCategory::Permission, 'permissions'], str_contains($body, '190,') => ['Account missing permissions, please reconnect and allow all permissions', ErrorCategory::Permission, '190'], - str_contains($body, 'param collaborators is not allowed') => ['Collaborators are not allowed for carousel', ErrorCategory::ContentPolicy, 'collaborators'], - // Content policy - str_contains($body, '2207001') => ['Instagram detected spam, try different content', ErrorCategory::ContentPolicy, '2207001'], - str_contains($body, '2207051') => ['Instagram blocked your request', ErrorCategory::ContentPolicy, '2207051'], + // Unknown str_contains($body, '2207027') => ['Unknown error, please try again later', ErrorCategory::Unknown, '2207027'], - default => [$json['error']['message'] ?? 'Instagram publishing failed', ErrorCategory::Unknown, (string) $errorSubcode], + default => [data_get($error, 'message', 'Instagram publishing failed'), ErrorCategory::Unknown, (string) $errorSubcode], }; return new static($message, $category, $code, $body); @@ -150,6 +196,8 @@ ### Per-Platform Exception (Example: Instagram) ### Error Maps Per Platform (from Postiz + API docs) +**Instagram** (30 errors): See example above. + **TikTok** (18 errors): - `access_token_invalid` -> TokenExpiredException - `scope_not_authorized`, `scope_permission_missed` -> Permission @@ -157,9 +205,9 @@ ### Error Maps Per Platform (from Postiz + API docs) - `file_format_check_failed`, `duration_check_failed`, `frame_rate_check_failed`, `picture_size_check_failed` -> MediaFormat - `video_pull_failed`, `photo_pull_failed` -> ServerError - `spam_risk_*` (5 variants) -> ContentPolicy -- `app_version_check_failed` -> Permission +- `app_version_check_failed`, `unaudited_client_can_only_post_to_private_accounts` -> Permission - `invalid_file_upload`, `invalid_params` -> MediaFormat -- `privacy_level_option_mismatch`, `url_ownership_unverified`, `unaudited_client_can_only_post_to_private_accounts` -> Permission +- `privacy_level_option_mismatch`, `url_ownership_unverified` -> Permission - `internal` -> ServerError **YouTube** (7 errors): @@ -178,45 +226,40 @@ ### Error Maps Per Platform (from Postiz + API docs) - `1404006`, `1404102`, `1404112` -> ServerError (upload failures) - `1609008`, `1609010` -> MediaFormat (video format/reel encoding) - `2061006` -> ContentPolicy (video too short) -- `1349125` -> RateLimit (rate limit exceeded) +- `1349125` -> RateLimit - `1363047` -> MediaFormat (reel encoding issue) - `Name parameter too long` -> ContentPolicy **LinkedIn** (2 errors): -- `Unable to obtain activity` -> ServerError (retry) -- `resource is forbidden` -> Permission (retry) +- `Unable to obtain activity` -> ServerError +- `resource is forbidden` -> Permission **X/Twitter** (3 errors): - `Unsupported Authentication` -> TokenExpiredException - `usage-capped` -> RateLimit - `duplicate-rules`, `invalid URL`, `video longer than 2 minutes` -> ContentPolicy -**Threads, Pinterest, Bluesky, Mastodon**: Postiz has no specific error maps. We will start with generic parsing of their error responses and add specific codes as we encounter them. +**Threads, Pinterest, Bluesky, Mastodon**: Postiz has no specific error maps for these. Start with generic parsing of their error responses and add specific codes as we encounter them in production. ## Integration with Publishers -Each publisher's `handleApiError` method will be replaced: +Each publisher's `handleApiError` method is replaced with the platform exception: ```php -// Before: +// Before (every publisher): private function handleApiError(Response $response, string $context): void { - // ... generic parsing ... + $body = $response->json() ?? []; + $error = $body['error'] ?? []; + // ... manual token check ... throw new \Exception("{$context}: {$message}"); } // After: -private function handleApiError(Response $response): void +private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $error = $body['error'] ?? []; - - // Check token errors first - if ($this->isTokenError($response, $error)) { - throw new TokenExpiredException(...); - } - - // Throw platform-specific exception with mapped message + // fromApiResponse handles token errors internally + // (throws TokenExpiredException for token issues) throw InstagramPublishException::fromApiResponse($response); } ``` @@ -228,31 +271,40 @@ ## Integration with PublishToSocialPlatform Job $result = $publisher->publish($this->postPlatform); $this->postPlatform->markAsPublished(...); } catch (TokenExpiredException $e) { - // ... existing token handling ... + Log::error('Token expired while publishing', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + ]); + $this->postPlatform->markAsFailed($e->getMessage()); + $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); } catch (SocialPublishException $e) { - Log::error('Social publish failed', $e->context()); + // context() is automatically included in the log by Laravel + Log::error('Social publish failed: ' . $e->userMessage); $this->postPlatform->markAsFailed($e->userMessage); } catch (\Throwable $e) { - Log::error('Unexpected publish error', [...]); + Log::error('Unexpected publish error', [ + 'post_platform_id' => $this->postPlatform->id, + 'error' => $e->getMessage(), + ]); $this->postPlatform->markAsFailed($e->getMessage()); } ``` -The `$e->userMessage` goes to the database (shown to user). The `$e->context()` goes to logs/Nightwatch with full debugging info. +The `$e->userMessage` goes to `error_message` in the database (shown to user). The `context()` method automatically provides platform, category, error code, and raw response to Nightwatch/logs. ## Testing -Each platform exception gets a test that verifies: -- Known error codes map to correct user messages -- Known error codes map to correct categories -- Unknown errors fall through to generic message -- Token errors are still handled by TokenExpiredException (not caught by platform exception) +Each platform exception gets a test file that verifies: +- Known error codes map to correct user messages and categories +- Token errors correctly throw `TokenExpiredException` (not `SocialPublishException`) +- Unknown errors fall through to generic message with `ErrorCategory::Unknown` ## Files Changed - Create: `app/Exceptions/Social/ErrorCategory.php` - Create: `app/Exceptions/Social/SocialPublishException.php` -- Create: 10 platform exception files +- Create: 10 platform exception files (`InstagramPublishException.php`, etc.) - Modify: 10 publisher files (replace `handleApiError` with platform exception) - Modify: `app/Jobs/PublishToSocialPlatform.php` (add `SocialPublishException` catch) - Create: 10 test files for platform exceptions