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:
parent
173a1e4c61
commit
1adef7787c
8 changed files with 399 additions and 52 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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 80000–80014 — 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
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue