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.
This commit is contained in:
Paulo Castellano 2026-08-08 12:24:10 -04:00 committed by GitHub
parent 173a1e4c61
commit 1adef7787c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 399 additions and 52 deletions

View file

@ -349,6 +349,17 @@ ## External Service URLs
- Tests: use the same `config(...)` value in `Http::fake([...])``Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes.
- Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.
## Meta (Facebook / Instagram / Threads) API Documentation (official sources)
When touching OAuth, token refresh, or error classification for Facebook/Instagram/Threads, consult these first — do not guess error codes or rate-limit behavior from memory. All three share the Graph API error format (`error.code`, `error.type`).
- General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/
- Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 8000080014 — e.g. `80001` Pages API, `80002` Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/
- Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/
- Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists.
- Our `App\Services\Social\Meta\GraphError` (used by `ConnectionVerifier`'s verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification.
- `Facebook`/`InstagramFacebook` `SocialAccount`s use a Facebook Page access token (BUC-limited); `Instagram` (direct login) and `Threads` use a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform.
## TryPost.it Documentation
- All our documentation to final user it's under https://docs.trypost.it

View file

@ -15,7 +15,6 @@ public static function fromApiResponse(mixed $response): static
$body = $response->json();
$rawResponse = $response->body();
$errorType = data_get($body, 'error.type');
$errorCode = data_get($body, 'error.code');
$errorSubcode = data_get($body, 'error.error_subcode');
$errorMessage = data_get($body, 'error.message', 'An unknown Facebook error occurred.');

View file

@ -15,7 +15,6 @@ public static function fromApiResponse(mixed $response): static
$body = $response->json();
$rawResponse = $response->body();
$errorType = data_get($body, 'error.type');
$errorCode = data_get($body, 'error.code');
$errorSubcode = data_get($body, 'error.error_subcode');
$errorUserMsg = data_get($body, 'error.error_user_msg');

View file

@ -16,7 +16,6 @@ public static function fromApiResponse(mixed $response): static
$rawResponse = $response->body();
$statusCode = $response->status();
$errorType = data_get($body, 'error.type');
$errorCode = data_get($body, 'error.code');
$errorMessage = data_get($body, 'error.message', 'An unknown Threads error occurred.');

View file

@ -319,7 +319,7 @@ private function refreshThreadsToken(SocialAccount $account): void
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
fn (?array $body) => ! GraphError::isTransient($body),
);
$data = $response->json();
@ -341,7 +341,7 @@ private function refreshInstagramToken(SocialAccount $account): void
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
fn (?array $body) => ! GraphError::isTransient($body),
);
$data = $response->json();
@ -415,13 +415,11 @@ private function verifyInstagram(SocialAccount $account): bool
'access_token' => $account->access_token,
]);
$body = $response->json() ?? [];
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
if ($response->successful()) {
return true;
}
return $response->successful();
throw GraphError::classifyVerifyFailure($response, 'Instagram');
}
private function verifyFacebook(SocialAccount $account): bool
@ -431,13 +429,11 @@ private function verifyFacebook(SocialAccount $account): bool
'access_token' => $account->access_token,
]);
$body = $response->json() ?? [];
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
if ($response->successful()) {
return true;
}
return $response->successful();
throw GraphError::classifyVerifyFailure($response, 'Facebook');
}
private function verifyThreads(SocialAccount $account): bool
@ -447,13 +443,11 @@ private function verifyThreads(SocialAccount $account): bool
'access_token' => $account->access_token,
]);
$body = $response->json() ?? [];
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Threads access token is invalid or expired');
if ($response->successful()) {
return true;
}
return $response->successful();
throw GraphError::classifyVerifyFailure($response, 'Threads');
}
private function verifyTikTok(SocialAccount $account): bool

View file

@ -4,27 +4,94 @@
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.
* 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" (e.g. code 4 / 17 "too many calls", code 1 / 2
* "temporary problem"), so neither the HTTP status nor the error type can tell
* a dead token from a throttle. Only error code 190 means the access token
* itself is invalid or expired the same signal the platform publish
* exceptions already use to decide a disconnect.
* 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
{
/**
* Whether the given Meta Graph error body means the access token is
* genuinely invalid or expired (code 190), as opposed to a rate-limit or
* transient error that must not disconnect a still-valid token.
* 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 indicatesInvalidToken(?array $body): bool
public static function isTransient(?array $body): bool
{
return data_get($body, 'error.code') === 190;
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");
}
}

View file

@ -634,7 +634,7 @@
->toThrow(TokenExpiredException::class);
});
test('instagram verify treats a Meta rate-limit (OAuthException code 4) as still-valid, not a disconnect', function () {
test('instagram verify treats a Meta rate-limit (OAuthException code 4) as transient, not a disconnect', function () {
Http::fake([
config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
@ -647,6 +647,232 @@
]);
// A rate-limit must NOT raise TokenExpiredException (which would disconnect);
// verify returns false and the caller leaves the account connected.
expect((new ConnectionVerifier)->verify($account))->toBeFalse();
// it must be surfaced as PlatformUnavailableException so the caller retries
// next cycle instead of silently — and permanently — treating it as valid.
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('threads verify treats a dead token reported under a non-190 code as genuinely expired', function () {
// Meta doesn't always report a dead Threads token as code 190 — this
// reproduces the "(#100) The requested resource does not exist" case
// from issue #230, which the old code === 190-only check let through
// as a silent, un-flagged "still valid".
Http::fake([
config('trypost.platforms.threads.graph_api').'/me*' => Http::response([
'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100],
], 400),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(TokenExpiredException::class);
});
test('threads verify treats a 5xx as platform unavailable, not a disconnect', function () {
Http::fake([
config('trypost.platforms.threads.graph_api').'/me*' => Http::response('upstream timeout', 503),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('facebook verify treats a dead token reported under a non-190 code as genuinely expired', function () {
Http::fake([
config('trypost.platforms.facebook.graph_api').'/me*' => Http::response([
'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100],
], 400),
]);
$account = SocialAccount::factory()->facebook()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(TokenExpiredException::class);
});
test('facebook verify treats a Meta rate-limit as transient, not a disconnect', function () {
Http::fake([
config('trypost.platforms.facebook.graph_api').'/me*' => Http::response([
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
], 400),
]);
$account = SocialAccount::factory()->facebook()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('facebook verify treats a Business Use Case rate-limit (Page token, code 80001) as transient, not a disconnect', function () {
// Facebook and InstagramFacebook accounts use Page tokens, which are
// throttled by BUC limits (code 80001) rather than Platform Rate Limits
// (codes 4/17) — and BUC rejections come back as a plain 400, not 429.
Http::fake([
config('trypost.platforms.facebook.graph_api').'/me*' => Http::response([
'error' => ['message' => 'There have been too many calls to this Page account.', 'code' => 80001],
], 400),
]);
$account = SocialAccount::factory()->facebook()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('instagram verify treats a Business Use Case rate-limit (code 80002) as transient, not a disconnect', function () {
Http::fake([
config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([
'error' => ['message' => 'Instagram Platform rate limit reached.', 'code' => 80002],
], 400),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('facebook verify treats a 5xx as platform unavailable, not a disconnect', function () {
Http::fake([
config('trypost.platforms.facebook.graph_api').'/me*' => Http::response('upstream timeout', 503),
]);
$account = SocialAccount::factory()->facebook()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('facebook verify treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () {
Http::fake([
config('trypost.platforms.facebook.graph_api').'/me*' => Http::response('<html>blocked</html>', 400),
]);
$account = SocialAccount::factory()->facebook()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('instagram verify treats a dead token reported under a non-190 code as genuinely expired', function () {
Http::fake([
config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([
'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100],
], 400),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(TokenExpiredException::class);
});
test('instagram verify treats a 5xx as platform unavailable, not a disconnect', function () {
Http::fake([
config('trypost.platforms.instagram.graph_api').'/me*' => Http::response('upstream timeout', 503),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('threads refresh treats a dead token reported under a non-190 code as genuinely expired', function () {
Http::fake([
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100],
], 400),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->subHour(),
]);
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
->toThrow(TokenExpiredException::class);
});
test('threads verify treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () {
// A WAF block page, truncated response, or gateway hiccup can return a
// 4xx with a body that isn't parseable JSON. There's no confirmed
// rejection from Meta in that case, so it must not disconnect the account.
Http::fake([
config('trypost.platforms.threads.graph_api').'/me*' => Http::response('<html>blocked</html>', 400),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(PlatformUnavailableException::class);
});
test('threads verify treats a failed response with a valid but unrecognized JSON shape as a confirmed rejection', function () {
// Unlike an unparseable body, a body that DID parse but has no "error"
// key at all is a real response from Meta, just not shaped like its
// usual error object. This is deliberately NOT treated as transient —
// an unrecognized 4xx shape still disconnects rather than being
// silently ignored, which is the exact bug this class replaced.
Http::fake([
config('trypost.platforms.threads.graph_api').'/me*' => Http::response(['data' => ['id' => '123']], 400),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->addDays(30),
]);
expect(fn () => (new ConnectionVerifier)->verify($account))
->toThrow(TokenExpiredException::class);
});
test('threads refresh treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () {
Http::fake([
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response('<html>blocked</html>', 400),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->subHour(),
]);
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
->toThrow(PlatformUnavailableException::class);
});
test('instagram refresh treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () {
Http::fake([
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response('<html>blocked</html>', 400),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->subHour(),
]);
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
->toThrow(PlatformUnavailableException::class);
});

View file

@ -2,34 +2,86 @@
declare(strict_types=1);
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Services\Social\Meta\GraphError;
use Illuminate\Support\Facades\Http;
test('code 190 indicates a genuinely invalid token', function () {
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190],
test('rate-limit and transient codes are transient', function () {
foreach ([1, 2, 4, 17] as $code) {
expect(GraphError::isTransient([
'error' => ['message' => 'temporary problem', 'type' => 'OAuthException', 'code' => $code],
]))->toBeTrue();
}
});
test('Business Use Case (BUC) rate-limit codes are transient', function () {
// Page/system-user tokens (our Facebook and InstagramFacebook accounts)
// and Instagram Platform are throttled by a separate rate-limit system
// from Platform Rate Limits (codes 4/17), with its own codes.
// https://developers.facebook.com/docs/graph-api/overview/rate-limiting/
expect(GraphError::isTransient([
'error' => ['message' => 'There have been too many calls to this Page account.', 'code' => 80001],
]))->toBeTrue();
expect(GraphError::isTransient([
'error' => ['message' => 'Instagram Platform rate limit reached.', 'code' => 80002],
]))->toBeTrue();
});
test('rate-limit codes carried as OAuthException do NOT indicate an invalid token', function () {
// Meta returns rate limits as HTTP 4xx with type OAuthException — they must
// stay transient so a throttle never disconnects a still-valid token.
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
test('code 190 and other confirmed rejections are not transient', function () {
expect(GraphError::isTransient([
'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190],
]))->toBeFalse();
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17],
expect(GraphError::isTransient([
'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100],
]))->toBeFalse();
});
test('transient codes do NOT indicate an invalid token', function () {
expect(GraphError::indicatesInvalidToken([
'error' => ['message' => 'Service temporarily unavailable', 'code' => 2],
]))->toBeFalse();
test('a body with no error is not transient, but a null (unparseable) body is', function () {
// A body Meta didn't return as parseable JSON (WAF block page, truncated
// response, gateway hiccup) carries no confirmed rejection — treat it as
// transient rather than assuming the token is dead. A body that DID parse
// but has no "error" key is a different case: it's a real response from
// the server we called, just not shaped like Meta's usual error object —
// intentionally NOT treated as transient, so an unrecognized 4xx shape
// still results in a confirmed rejection rather than being silently
// ignored (the exact bug this class was rewritten to close).
expect(GraphError::isTransient(null))->toBeTrue();
expect(GraphError::isTransient([]))->toBeFalse();
expect(GraphError::isTransient(['data' => ['id' => '123']]))->toBeFalse();
});
test('a body with no error, or a null body, does not indicate an invalid token', function () {
expect(GraphError::indicatesInvalidToken(null))->toBeFalse();
expect(GraphError::indicatesInvalidToken([]))->toBeFalse();
expect(GraphError::indicatesInvalidToken(['data' => ['id' => '123']]))->toBeFalse();
test('isTransientFailure treats 5xx and 429 as transient regardless of body', function () {
Http::fake(['example.com/*' => Http::response('upstream timeout', 503)]);
expect(GraphError::isTransientFailure(Http::get('https://example.com/me')))->toBeTrue();
Http::fake(['example.com/*' => Http::response(['error' => ['code' => 190]], 429)]);
expect(GraphError::isTransientFailure(Http::get('https://example.com/me')))->toBeTrue();
});
test('isTransientFailure classifies a confirmed 4xx rejection as not transient', function () {
Http::fake(['example.com/*' => Http::response(['error' => ['code' => 190]], 400)]);
expect(GraphError::isTransientFailure(Http::get('https://example.com/me')))->toBeFalse();
});
test('classifyVerifyFailure returns PlatformUnavailableException for a transient failure', function () {
Http::fake(['example.com/*' => Http::response('upstream timeout', 503)]);
expect(GraphError::classifyVerifyFailure(Http::get('https://example.com/me'), 'Threads'))
->toBeInstanceOf(PlatformUnavailableException::class);
});
test('classifyVerifyFailure returns TokenExpiredException for a confirmed rejection', function () {
Http::fake([
'example.com/*' => Http::response([
'error' => ['message' => 'The requested resource does not exist', 'code' => 100],
], 400),
]);
$exception = GraphError::classifyVerifyFailure(Http::get('https://example.com/me'), 'Threads');
expect($exception)->toBeInstanceOf(TokenExpiredException::class)
->and($exception->getMessage())->toBe('Threads access token is invalid or expired');
});