Skip to content

fix(social): proactive token refresh actually refreshes (not just verifies) - #30

Merged
paulocastellano merged 1 commit into
mainfrom
fix/proactive-token-refresh
May 12, 2026
Merged

fix(social): proactive token refresh actually refreshes (not just verifies)#30
paulocastellano merged 1 commit into
mainfrom
fix/proactive-token-refresh

Conversation

@paulocastellano

Copy link
Copy Markdown
Contributor

Context

After #29 shipped, an audit of the daily commands revealed that the hourly proactive-refresh cron was silently letting tokens age out and die at the provider. The original failure (PR #29) was the visible symptom; this PR fixes the underlying cause so the same situation doesn't recur.

Why tokens were dying despite the hourly cron:

  1. RefreshSocialToken job called ConnectionVerifier::verify() — which is optimized for the publish-time path and tries the access_token first. If verify succeeds, no refresh happens. So the cron's whole purpose (refresh proactively) was defeated by verify()'s smart-skip.
  2. RefreshExpiringTokens command excluded already-expired tokens via where('token_expires_at', '>', now()). So if a token aged out between cron passes, it never got a last-chance refresh — by the time anyone noticed, the refresh_token at the provider had also been revoked.
  3. When RefreshSocialToken did fail, it just logged a warning and left the account Connected. User was never notified, the cron kept retrying every hour, and the account only flipped to TokenExpired when the user tried to publish (PR fix(social): unify token-expired handling across all publishers #29's fix) or the daily CheckSocialConnections ran.

The three orthogonal fixes

(C) ConnectionVerifier::refreshToken is now public

Renamed from private refreshTokenIfNeeded → public refreshToken. The body is unchanged (lock + per-platform refresh dispatch). verify() still uses it internally. Callers that want a proactive refresh (the cron) call it directly and skip verify()'s smart-skip logic.

(B) Cron window widened to include already-expired tokens

Removed where('token_expires_at', '>', now()) in RefreshExpiringTokens. Already-expired-but-still-Connected tokens now get a last-chance refresh attempt before the refresh_token also dies at the provider. The status = Connected filter still excludes accounts already in TokenExpired or Disconnected state.

(D) RefreshSocialToken marks TokenExpired on refresh failure

Two separate catch branches now:

  • TokenExpiredException (refresh_token rejected → terminal): call markAsTokenExpired($e->getMessage()). The lock + transition detection in markAsTokenExpired (from fix(social): unify token-expired handling across all publishers #29) means user gets exactly one notification per transition, no spam if subsequent cron passes retry.
  • Throwable (network blip, 5xx, etc. → transient): log warning, account stays Connected, next cron pass tries again.

End-to-end behavior now

Happy path:

  • Token expires in 2h → cron picks it up → refresh succeeds → new tokens persisted → cycle continues

Refresh_token revoked at provider:

  • Cron picks it up → refresh fails with TokenExpiredException → account marked TokenExpired → user notified (in-app + email) immediately → subsequent cron passes filter it out (status = Connected no longer matches) → no spam

Already-expired token (B fix):

  • Token expired at 02:00, missed previous cron window → 03:00 cron now includes it (no more > now filter) → either succeeds (provider hasn't reaped yet) or falls into the TokenExpired path above

Test plan

  • php artisan test --compact --parallel1502 passed, 2 skipped, 0 failed (+3 over main, all new)
  • New: refresh job calls refreshToken (not verify) on the verifier
  • New: refresh job marks account as TokenExpired when refresh_token is rejected
  • New: refresh job logs warning on non-token errors and leaves status alone
  • Updated: it dispatches refresh jobs for tokens expiring within 2 hours or already expired (flipped from "should NOT" to "SHOULD" for already-expired)
  • Production data check: 0 stuck accounts (status=connected + token_expires_at < NOW()) — no notification spike on deploy
  • Manual: trigger social:refresh-expiring-tokens against a real expired X account → confirm markAsTokenExpired fires + email arrives

Known gaps (out of scope, follow-up)

  • Tokens with token_expires_at = null (Facebook pages, Mastodon, Bluesky, Instagram-via-Facebook) are still only checked by the daily CheckSocialConnections. Provider-side revocations on these accounts have up to a 24h discovery window. Worth a separate PR if it becomes a problem.

…ifies)

Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.

The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.

(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
    public `refreshToken`. Callers that want the smart 'try
    access_token first' behavior keep using `verify()`. Callers that
    want a proactive refresh (the cron) call `refreshToken` directly.

(B) RefreshExpiringTokens command: drop the
    `where('token_expires_at', '>', now())` filter. Already-expired
    tokens now get a last-chance refresh attempt before the
    refresh_token also dies at the provider. Status filter
    (`Connected`) still excludes accounts already marked TokenExpired.

(D) RefreshSocialToken job: switch from `verify()` to
    `refreshToken()`, and on `TokenExpiredException` call
    `markAsTokenExpired` so the user is notified immediately. The lock
    + transition detection in markAsTokenExpired prevents notification
    spam if subsequent cron passes also fail.

Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
  marks TokenExpired on TokenExpiredException, logs warning on other
  errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
  are now dispatched (was previously asserted as 'should NOT')
@paulocastellano
paulocastellano merged commit 6c2c555 into main May 12, 2026
2 checks passed
@paulocastellano
paulocastellano deleted the fix/proactive-token-refresh branch May 12, 2026 22:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant