trypost/tests/Unit/Services/Social/Meta/GraphErrorTest.php

88 lines
4 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
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 16:24:10 +00:00
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Services\Social\Meta\GraphError;
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 16:24:10 +00:00
use Illuminate\Support\Facades\Http;
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 16:24:10 +00:00
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();
});
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 16:24:10 +00:00
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();
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 16:24:10 +00:00
expect(GraphError::isTransient([
'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100],
]))->toBeFalse();
});
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 16:24:10 +00:00
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();
});
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 16:24:10 +00:00
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');
});