Fix social account disconnects: refresh tokens per platform's refresh model#171
Merged
Conversation
X OAuth2 refresh tokens are single-use: each refresh rotates the pair and invalidates the previous refresh_token, and reusing a rotated one kills the whole family. Three things made this fragile and disconnected accounts far more often than necessary: - The proactive refresh job called refreshToken() directly, bypassing the access-token-first guard in verify() and rotating on every run. - RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the 2h access-token lifetime — so every X account was rotated every hour even while its token was still valid. - A single 4xx refresh failure disconnected the account without checking whether a concurrent refresh had already persisted a working token. Changes: - RefreshSocialToken now routes through verify() (access-token-first), so it only rotates when the access_token is actually invalid. - Shrink the proactive window to 30m and run the command every 15m, so the window still covers the run interval but rotation happens near real expiry. - verify() tolerates the lost-rotation race: on a 4xx refresh, reload and verify with a concurrently-refreshed token before marking TokenExpired. Refs #126
Every publisher and analytics service proactively refreshed the token when it was expired OR merely "expiring soon" (within 15 min), calling refreshToken() directly. For X (and other single-use-refresh providers) that rotated a perfectly valid access_token whenever an operation ran in the token's final 15 minutes — the same needless rotation that breaks the refresh_token chain and disconnects accounts. Narrow every pre-check to refresh only when the token is actually expired. A still-valid token is used as-is; if it expires mid-operation the existing reactive retry (PublishToSocialPlatform) refreshes and retries. - Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks. - Remove the now-unused `isTokenExpiringSoon` accessor (no references remain anywhere in the repo). - The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and the expired-token path are unchanged. Refs #126
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).
Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().
Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.
Refs #126
The extend-while-valid path is shared by Instagram and Threads via Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the Instagram job test so both extension-model platforms are locked down. Refs #126
Extension-model tokens (Instagram/Threads) can't be refreshed once they expire, so the shared 30-minute cron window left only a ~15-minute buffer against queue backlog on the default queue — and a lapse forces a full reconnect. Rotating platforms go through verify() and won't rotate a still-valid token, so they keep the tight 30-minute window; extension platforms now get a 24-hour lead via a per-model query.
A null token_expires_at drops an account from every refresh path (the cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon checks all treat null as "nothing to do"), so the token silently lapses. Threads could persist null two ways: the long-lived exchange failing at connect (kept the ~1h short-lived token) — now fails the connect instead; and a refresh response omitting expires_in — now defaults to 60 days for both Instagram and Threads, matching the X refresh convention.
…lper The 60-day fallback used when Meta omits expires_in was duplicated as a bare 5184000 across the Instagram/Threads connect and refresh code; it now lives in one place, Platform::LONG_LIVED_TOKEN_TTL_SECONDS. Also renames Platform::extensionModelValues() to accessTokenExtendingPlatformValues() so the name states what it returns without needing the extendsAccessTokenOnRefresh docblock.
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as HTTP 4xx with type OAuthException, so a throttled proactive refresh was disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min refresh cadence raised the odds of hitting it. Classification now keys on error code 190 (the signal the publish exceptions already use): only a genuine 190 disconnects; every other Meta 4xx is treated as transient (PlatformUnavailable) and retried next cycle. The same over-broad type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced with the shared Meta\GraphError helper so verify and refresh agree.
The connect flow logged the raw response body of a failed token exchange, unlike the TokenRedactor discipline used everywhere else. A failure body carries no token, but redacting keeps it consistent and defensive.
refreshThenVerify only guarded refreshToken(); the verify that followed was outside the try, so the sub-commit window where a lock-skipped refresh reloads a not-yet-persisted token surfaced as a 401 there and falsely disconnected a still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the verify that follows it now share one recovery: on a TokenExpiredException, reload and, if a concurrent refresh has since persisted a fresh access_token, verify with it instead of giving up.
…tTokenTtlSeconds() The single LONG_LIVED_TOKEN_TTL_SECONDS constant (Meta 60-day) plus a loose inline 7200 for X made it unclear which networks each value applied to. Express the fallback TTL as a per-platform match method instead, matching how the enum already exposes every other per-network value, so the network->value mapping is visible in one place: X 2h, Instagram/Threads 60d, everyone else null (they always return expires_in). Behavior is unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #126.
Problem
Social accounts were being marked disconnected far more often than they should be, forcing frequent reconnects. The root cause was proactive token refresh that ignored how each platform actually refreshes its tokens — and it broke in two opposite ways.
Rotating refresh-token platforms (X, LinkedIn, TikTok, Pinterest, YouTube, Bluesky). Their refresh tokens are single-use / rotating — every refresh returns a new pair and invalidates the old one; sending an already-rotated token can invalidate the whole token family and force a reconnect. The old code rotated far too often:
RefreshExpiringTokensrefreshed anything expiring within 2h and ran hourly, but X access tokens live exactly 2h — so every X account was rotated every hour while still valid.RefreshSocialTokencalledrefreshToken()directly, bypassingverify()'s access-token-first guard.Extension-token platforms (Instagram, Threads). These have no separate refresh token — they refresh by extending the access token itself, and per Meta's docs the token must be unexpired to refresh ("at least 24 hours old but has not expired"; once expired it "can no longer be refreshed"). So they must be refreshed while still valid — the opposite of the rotating platforms.
Fix
Gate proactive refresh on the platform's refresh model:
Platform::extendsAccessTokenOnRefresh()— true only for Instagram/Threads.SocialAccount::needsProactiveTokenRefresh()— refresh when expired for rotating platforms, OR when expiring-soon for extension platforms.RefreshSocialToken): route rotating platforms throughverify()(access-token-first) so they only rotate once actually expired; extend (refreshToken()) extension platforms while still valid.RefreshExpiringTokens, cron every 15 min): rotating platforms use a tight 30-minute window (verify-first means being in-window just re-verifies, never rotates a still-valid token); extension platforms (Instagram/Threads) get a 24-hour lead, because they can't be refreshed once expired and the job runs on the backlog-pronedefaultqueue — a short window risked a permanent lapse under queue delay.token_expires_atsilently drops an account from every refresh path (the cron'swhereNotNulland theis_token_expired/is_token_expiring_soonchecks), so the token lapses. Threads no longer completes a connect with only the ~1h short-lived token when the long-lived exchange fails (it fails the connect so the user retries), and a refresh that omitsexpires_innow defaults to 60 days (Platform::LONG_LIVED_TOKEN_TTL_SECONDS, centralized) for both Instagram and Threads.type: OAuthException, andTokenRefreshClientclassified every non-5xx/429 failure as a dead token → a throttled proactive refresh disconnected still-valid tokens (and the wider refresh cadence raised the odds). Classification now keys on error code 190 — the signal the publish exceptions already use — via a sharedMeta\GraphErrorhelper: only a genuine 190 disconnects; every other Meta 4xx is transient (PlatformUnavailable) and retried next cycle. The same over-broadtype-based check inverifyInstagram/verifyFacebook/verifyThreadswas replaced with the helper so verify and refresh agree.verify()now reloads and uses a concurrently-refreshed token before marking an account expired.needsProactiveTokenRefresh().Platform audit
All 13 platforms were checked against the code and the providers' docs:
whereNotNull): Facebook, Instagram-via-Facebook, Mastodon, Telegram, Discord.Cold review of existing-token / deploy safety
An adversarial pass over the existing
social_accountsdata confirmed no currently-connected row is broken, disconnected, or unnecessarily rotated versusmain: every divergence either reduces rotation/false-disconnects for rotating platforms, widens the harmless extend window for Instagram/Threads, or is a no-op for the null-expiry / never-expire rows. The only real risk it surfaced — the Meta rate-limit false-disconnect above — is fixed in this PR.Tests
verify()(still-valid X token is not rotated) and extends still-valid Instagram and Threads tokens.expires_inis omitted; Instagram/Threads refresh do the same.OAuthExceptioncode 4/17 on refresh raisesPlatformUnavailable(not a disconnect), code 190 raisesTokenExpired; the proactive job leaves a rate-limited Instagram account Connected;verifyInstagramreturns false (no disconnect) on a rate-limit;Meta\GraphErrorunit-tested for 190 vs 4/17/2/none.ConnectionVerifierregression test: a 4xx refresh with a concurrently-rotated token does not disconnect the account.needsProactiveTokenRefresh()model test covers the rotating-vs-extension branching.