trypost/app/Services/Social/Meta/GraphError.php
Paulo Castellano 1adef7787c
fix: detect dead Threads/Instagram/Facebook tokens reported under non-190 codes (#254)
* fix: detect dead Threads/Instagram/Facebook tokens reported under non-190 codes

verifyThreads/verifyInstagram/verifyFacebook only threw TokenExpiredException
for Meta error code 190, silently returning false for every other rejection
(e.g. code 100 "The requested resource does not exist"). The hourly
VerifyWorkspaceConnections check never saw that false, so a genuinely dead
token went unflagged — no reconnect email — until the real scheduled post
tried to publish and failed with the same raw error (#230).

GraphError::isTransient() now isolates the known rate-limit/transient codes
(1, 2, 4, 17); everything else on a failed verify/refresh is a confirmed
rejection and raises TokenExpiredException, while transient/5xx/429 raises
PlatformUnavailableException so the account isn't disconnected on a throttle.

Also drops the unused $errorType variable from the three *PublishException
classes.

* fix: treat unparseable Meta failure bodies as transient, drop dead code

Code review on #254 found two issues in the original fix:

- The inverted classifier (`! GraphError::isTransient($body)`) treated a
  response body that fails to parse as JSON (WAF block page, truncated
  response, gateway hiccup) as a confirmed dead token, since isTransient()
  returns false for a body it can't recognize. That flipped a null/unparseable
  body from "retry later" (PlatformUnavailableException, the pre-fix behavior)
  to "disconnect now" (TokenExpiredException) for both the Threads/Instagram
  refresh classifiers and the verify path's classifyMetaVerifyFailure. Fixed
  by treating a null body as transient at both call sites — we have no
  confirmed rejection from Meta to act on.
- GraphError::indicatesInvalidToken() had no remaining production callers
  after the refresh classifiers switched to isTransient() — removed it and
  its tests instead of leaving dead code behind.

* test: symmetric Facebook/Instagram coverage for the shared verify classifier

verifyInstagram/verifyFacebook/verifyThreads all delegate to the same
classifyMetaVerifyFailure(), so the non-190 dead-token, rate-limit,
5xx, and non-JSON-body cases were only exercised end-to-end for
Threads. Adds the missing Facebook (rate-limit, 5xx, non-JSON) and
Instagram (non-190 dead token, 5xx) cases so each platform has direct
proof, not just shared-code inference.

* fix: recognize Business Use Case (BUC) rate-limit codes for Page-token accounts

Verified the transient-code list against Meta's official docs. Confirmed:
codes 1, 2, 4, 17, 190 match what's documented at
developers.facebook.com/docs/graph-api/guides/error-handling/. But Meta runs
a SECOND, separately-coded rate-limit system (Business Use Case / BUC) for
Page and system-user tokens — which is exactly what our Facebook and
InstagramFacebook accounts use. BUC rejections come back as a plain HTTP 400
(not 429) with codes in the 80000 range (80001 Pages API, 80005 Instagram
Platform), which GraphError::isTransient() didn't recognize — meaning a
throttled Facebook/InstagramFacebook Page token would have been misclassified
as a confirmed dead token and disconnected.

- Added 80001/80005 to GraphError::TRANSIENT_CODES, with sources.
- Added GraphError::isTransientFailure(Response) to fold the status-based
  checks (5xx, 429) and body-based checks together into one documented
  method, replacing the ad-hoc multi-condition `if` that lived inline in
  ConnectionVerifier::classifyMetaVerifyFailure().
- isTransient() now treats a null (unparseable) body as transient directly,
  so the refresh-path classifiers no longer need a separate null guard.
- Documented the full code table, sources, and per-platform token-type
  notes (Page token vs. user token, which rate-limit system applies to
  which platform) in GraphError's class docblock and in CLAUDE.md, so
  future changes here start from verified sources instead of guessing.

* refactor: move Meta verify-failure classification into GraphError

classifyMetaVerifyFailure() lived in ConnectionVerifier but never touched
$this, SocialAccount, or the cache lock — it was a pure (Response, label) ->
Exception translation, same shape as what TokenRefreshClient already owns
for the refresh side. Keeping it in ConnectionVerifier broke that symmetry
and split Meta error interpretation across two classes instead of the one
(GraphError) whose docblock already says that's its job.

Moved as GraphError::classifyVerifyFailure(), dropped the now-unused
Response import from ConnectionVerifier, and added direct unit tests for
the new public method alongside the existing ConnectionVerifierTest
coverage that exercises it through verify().

* fix: correct Instagram Platform BUC code from 80005 to 80002

My earlier WebFetch of Meta's rate-limiting page mis-parsed the BUC code
table and mapped 80005 to Instagram Platform. It's actually Lead Generation
(Marketing API, which this app never calls) — Instagram Platform is 80002.
Verified against a raw, unsummarized reproduction of the same official page
(developers.facebook.com/docs/graph-api/overview/rate-limiting/) plus
independent third-party corroboration, both pointing to 80002.

Also closes a test-coverage gap flagged in review: GraphError::isTransient()
now intentionally treats a parseable body with no "error" key (e.g.
{"data": {...}}) as a confirmed rejection, not transient — a real behavior
change from the pre-#254 code, which silently ignored that shape. Added
explicit unit + integration coverage for it so the decision is asserted,
not implicit.
2026-08-08 13:24:10 -03:00

97 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Social\Meta;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
/**
* Interprets Meta Graph API (Facebook / Instagram / Threads) error responses
* for the connection-health path (ConnectionVerifier's verify/refresh calls
* against `/me` and `/refresh_access_token`). Does not cover the much larger,
* platform-specific content-publishing error maps in
* FacebookPublishException/InstagramPublishException/ThreadsPublishException.
*
* Meta returns rate-limit and transient failures as an ordinary HTTP 4xx with
* type "OAuthException", so neither the HTTP status nor the error type alone
* can tell a dead token from a throttle. Two independent rate-limit systems
* exist and both must be treated as transient:
*
* - Platform Rate Limits (app/user access tokens — Instagram and Threads
* accounts in this app): code 4 "app rate limit", code 17 "user rate
* limit". https://developers.facebook.com/docs/graph-api/guides/error-handling/
* - Business Use Case (BUC) Rate Limits (Page/system-user tokens — Facebook
* and InstagramFacebook accounts here use Page tokens): code 80001 "Pages
* API", code 80002 "Instagram Platform". Unlike Platform Rate Limits, BUC
* rejections come back as an ordinary HTTP 400, not 429. BUC also covers
* several other Meta products (Marketing API, WhatsApp, Messenger, ...)
* with their own 80000-series codes — irrelevant here since this app never
* calls those APIs; add a code only once we actually call the surface it
* belongs to, verified against the table below, not guessed.
* https://developers.facebook.com/docs/graph-api/overview/rate-limiting/
* - Generic transient upstream problems: code 1, code 2.
*
* Only a known transient code (or a body Meta didn't return as parseable
* JSON at all — a WAF block page, a truncated response, a gateway hiccup —
* which carries no confirmed rejection either) means the failure isn't a
* confirmed rejection. Every other 4xx — including error codes other than
* 190, which Meta also uses to signal a dead token (e.g. code 100 seen on a
* genuinely revoked Threads token) — means the account needs to be
* reconnected.
*/
class GraphError
{
/**
* Codes Meta uses for rate-limit and other transient upstream problems.
* These must never disconnect a still-valid token.
*/
private const TRANSIENT_CODES = [1, 2, 4, 17, 80001, 80002];
/**
* Whether the given Meta Graph error body is a known rate-limit or
* transient upstream problem, as opposed to a confirmed rejection
* (dead token, bad request, permission denied, etc.). A body that
* doesn't parse as JSON is treated as transient too — we have no
* confirmed rejection from Meta to act on.
*
* @param array<string, mixed>|null $body
*/
public static function isTransient(?array $body): bool
{
return $body === null || in_array(data_get($body, 'error.code'), self::TRANSIENT_CODES, true);
}
/**
* Whether a failed Meta Graph API response — status and body together —
* represents a transient problem that must not disconnect a still-valid
* token, as opposed to a confirmed rejection.
*/
public static function isTransientFailure(Response $response): bool
{
return $response->serverError()
|| $response->status() === 429
|| self::isTransient($response->json());
}
/**
* Classify a failed Meta Graph "/me" verify call into the exception the
* caller should throw: a rate-limit or other transient upstream problem
* must not disconnect a still-valid token, but every other rejection —
* including error codes other than 190, which Meta also uses to signal a
* dead token — means the account genuinely needs to be reconnected.
*/
public static function classifyVerifyFailure(Response $response, string $label): PlatformUnavailableException|TokenExpiredException
{
if (self::isTransientFailure($response)) {
return new PlatformUnavailableException(
"{$label} API returned {$response->status()} during verification",
$response->status(),
);
}
return new TokenExpiredException("{$label} access token is invalid or expired");
}
}