ngrx token refresh effect: how to avoid infinite retry loops when backend is down
0 reputation · 28 Jul 2023, 02:39 UTC
0 reputation · 28 Jul 2023, 02:39 UTC
In many ngrx applications the AuthEffects intercept a RefreshToken action, call the backend, and dispatch a new SetToken action. When the backend is unreachable, the effect may keep re‑dispatching RefreshToken endlessly because no retry limit is defined.
Additionally, the meta‑reducer that clears the store on Logout must decide which slices to wipe and which to keep. The library offers no guidance, leading to inconsistent preservation of UI state such as theme or layout.
localStorage and trigger a single logout action without race conditions across multiple tabs?32275 reputation · 28 Jul 2023, 11:57 UTC
The infinite loop occurs because the effect re-dispatches RefreshToken on every failure. Replace that pattern with a capped retry pipeline that terminates in a dedicated failure action.
refresh$ = createEffect(() => this.actions$.pipe(
ofType(AuthActions.refreshToken),
concatLatestFrom(() => this.store.select(selectAuthConfig)),
switchMap(([, config]) => this.authApi.refresh().pipe(
map(tokens => AuthActions.setTokens({ tokens })),
retry({
count: config.maxRefreshAttempts ?? 3,
delay: (err, retryCount) => {
if (!this.isRetryable(err)) {
throw err; // 401/403 — do not retry
}
return timer(retryCount * (config.refreshRetryDelayMs ?? 1000));
},
}),
catchError(err => of(AuthActions.refreshTokenFailure({ error: err })))
))
));isRetryable returns true only for network errors or 5xx responses. A 401/403 from the refresh endpoint means the refresh token itself is invalid — retrying is futile and should go straight to logout. The reducer (or a follow-up effect) handles refreshTokenFailure by dispatching a single Logout.
Define an InjectionToken<AuthRetryConfig> with maxRefreshAttempts and refreshRetryDelayMs, provided through app config or environment files. Tests and staging/prod deployments can then tune values without code changes, and unit tests can set maxRefreshAttempts: 0 to assert the failure path directly.
Default to clearing the entire store on Logout. Whitelist only non-sensitive UI slices (theme, layout, language) by re-applying them after the reset:
export const logoutMetaReducer: MetaReducer<State> =
(reducer) => (state, action) => {
if (action.type === AuthActions.logout.type && state) {
const { theme, language } = state.ui;
const fresh = reducer(undefined, action); // full reset to initial state
return { ...fresh, ui: { ...fresh.ui, theme, language } };
}
return reducer(state, action);
};This least-privilege approach — clear by default, preserve by explicit opt-in — avoids accidentally retaining tokens, user profiles, or cached API responses. Never invert it into an opt-out wipe list; new feature slices would silently leak across sessions.
exp claim (or a stored expiry timestamp) on app load rather than blindly calling refresh.timer(expiry - Date.now() - bufferMs).localStorage and dispatches SetTokens itself.storage event and update their store via the same action — no second network call.concatLatestFrom(() => this.store.select(selectIsAuthenticated)) and short-circuit if already logged out, so duplicate logout dispatches are no-ops.The writing tab must update its own state directly because the storage event does not fire in the tab that wrote the value. Note also that tokens in localStorage are readable by any script in the origin, so weigh XSS exposure before relying on this for cross-tab sync.
RefreshTokenFailure/Logout — check the NgRx Store DevTools action log.Logout and inspect the state tree to confirm only whitelisted UI slices survive.Which NgRx and RxJS major versions is the project on? The retry({count, delay}) config object requires RxJS 7.3+ (older versions need retryWhen with a scan counter), and concatLatestFrom arrived in NgRx 14 — on older versions use withLatestFrom plus a filter.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.