Перехват 401 ошибок

Код ответа 401 Unauthorized означает, что сервер не принимает текущие учетные данные пользователя. В большинстве приложений это связано с:

  • истёкшим access token;
  • отсутствующим токеном;
  • повреждённым JWT;
  • удалённой сессией;
  • отзывом refresh token;
  • принудительным выходом пользователя.

В RTK Query перехват таких ошибок обычно реализуется внутри baseQuery, поскольку именно этот слой отвечает за все сетевые запросы.

Стандартный поток обработки выглядит следующим образом:

  1. Запрос отправляется с access token.
  2. Сервер возвращает 401.
  3. Клиент выполняет запрос обновления токена.
  4. Новый access token сохраняется.
  5. Исходный запрос повторяется автоматически.
  6. Если refresh token также недействителен — выполняется logout.

Базовая структура API

Наиболее распространённая архитектура начинается с создания собственного baseQuery.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const baseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.accessToken

        if (token) {
            headers.set('authorization', `Bearer ${token}`)
        }

        return headers
    }
})

export const api = createApi({
    reducerPath: 'api',
    baseQuery,
    endpoints: () => ({})
})

Здесь fetchBaseQuery автоматически добавляет токен в заголовок Authorization.


Почему стандартного fetchBaseQuery недостаточно

fetchBaseQuery умеет:

  • отправлять запросы;
  • сериализовать JSON;
  • обрабатывать ошибки;
  • добавлять заголовки.

Однако он не содержит встроенной логики:

  • обновления токенов;
  • повторных запросов;
  • синхронизации refresh-процессов;
  • централизованного logout.

Поэтому поверх него создаётся обёртка.


Создание обёртки над baseQuery

Наиболее типичная схема:

import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const rawBaseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.accessToken

        if (token) {
            headers.set('authorization', `Bearer ${token}`)
        }

        return headers
    }
})

const baseQueryWithReauth = async (args, api, extraOptions) => {
    let result = await rawBaseQuery(args, api, extraOptions)

    if (result.error && result.error.status === 401) {
        console.log('Unauthorized')
    }

    return result
}

Теперь все ответы проходят через единый промежуточный слой.


Полный механизм refresh token

Общая последовательность

После получения 401 необходимо:

  1. Выполнить запрос /refresh.
  2. Получить новый access token.
  3. Сохранить его в Redux.
  4. Повторить исходный запрос.

Хранилище авторизации

Пример slice:

import { createSlice } from '@reduxjs/toolkit'

const initialState = {
    accessToken: null,
    user: null
}

const authSlice = createSlice({
    name: 'auth',
    initialState,
    reducers: {
        setCredentials: (state, action) => {
            state.accessToken = action.payload.accessToken
            state.user = action.payload.user
        },

        logout: (state) => {
            state.accessToken = null
            state.user = null
        }
    }
})

export const {
    setCredentials,
    logout
} = authSlice.actions

export default authSlice.reducer

Реализация автоматического refresh

import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { setCredentials, logout } from './authSlice'

const rawBaseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    credentials: 'include',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.accessToken

        if (token) {
            headers.set('authorization', `Bearer ${token}`)
        }

        return headers
    }
})

const baseQueryWithReauth = async (args, api, extraOptions) => {
    let result = await rawBaseQuery(args, api, extraOptions)

    if (result.error && result.error.status === 401) {

        const refreshResult = await rawBaseQuery(
            {
                url: '/auth/refresh',
                method: 'POST'
            },
            api,
            extraOptions
        )

        if (refreshResult.data) {

            api.dispatch(
                setCredentials(refreshResult.data)
            )

            result = await rawBaseQuery(
                args,
                api,
                extraOptions
            )

        } else {

            api.dispatch(logout())
        }
    }

    return result
}

Почему используется credentials: ‘include’

Часто refresh token хранится в:

  • HttpOnly cookie;
  • secure cookie;
  • sameSite cookie.

В этом случае браузер не отправляет cookie автоматически без:

credentials: 'include'

Без этой настройки refresh-запрос будет выполняться без cookie, и сервер всегда вернёт 401.


Повтор исходного запроса

Ключевой момент:

result = await rawBaseQuery(
    args,
    api,
    extraOptions
)

args содержит оригинальный запрос:

{
    url: '/users',
    method: 'GET'
}

После обновления токена запрос отправляется повторно уже с новым access token.


Проблема параллельных 401 запросов

Одна из самых опасных ситуаций возникает при множественных запросах.

Например:

/users
/profile
/posts
/notifications

Все запросы одновременно получают 401.

Без защиты приложение выполнит:

POST /refresh
POST /refresh
POST /refresh
POST /refresh

Это приводит к:

  • гонкам состояний;
  • перезаписи токенов;
  • инвалидированию refresh token;
  • случайным logout;
  • повреждению сессии.

Mutex для синхронизации refresh

Наиболее популярное решение — использование async-mutex.

Установка:

npm install async-mutex

Реализация mutex

import { Mutex } from 'async-mutex'

const mutex = new Mutex()

Полная реализация с mutex

import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Mutex } from 'async-mutex'

import {
    setCredentials,
    logout
} from './authSlice'

const mutex = new Mutex()

const rawBaseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    credentials: 'include',

    prepareHeaders: (headers, { getState }) => {

        const token = getState().auth.accessToken

        if (token) {
            headers.set(
                'authorization',
                `Bearer ${token}`
            )
        }

        return headers
    }
})

const baseQueryWithReauth = async (
    args,
    api,
    extraOptions
) => {

    await mutex.waitForUnlock()

    let result = await rawBaseQuery(
        args,
        api,
        extraOptions
    )

    if (
        result.error &&
        result.error.status === 401
    ) {

        if (!mutex.isLocked()) {

            const release = await mutex.acquire()

            try {

                const refreshResult = await rawBaseQuery(
                    {
                        url: '/auth/refresh',
                        method: 'POST'
                    },
                    api,
                    extraOptions
                )

                if (refreshResult.data) {

                    api.dispatch(
                        setCredentials(
                            refreshResult.data
                        )
                    )

                    result = await rawBaseQuery(
                        args,
                        api,
                        extraOptions
                    )

                } else {

                    api.dispatch(logout())
                }

            } finally {

                release()
            }

        } else {

            await mutex.waitForUnlock()

            result = await rawBaseQuery(
                args,
                api,
                extraOptions
            )
        }
    }

    return result
}

Как работает mutex

Первый запрос

Первый запрос получает 401:

GET /users -> 401

Он захватывает mutex:

const release = await mutex.acquire()

После этого остальные запросы блокируются.


Остальные запросы

Они доходят до:

await mutex.waitForUnlock()

и ожидают завершения refresh-процесса.


После refresh

Первый запрос:

  1. обновляет токен;
  2. освобождает mutex;
  3. остальные запросы продолжаются;
  4. используют уже новый токен.

Защита от бесконечного цикла refresh

Очень опасная ошибка:

401 -> refresh -> 401 -> refresh -> 401

Такой цикл способен полностью заблокировать приложение.

Поэтому refresh endpoint никогда не должен повторно запускать refresh.


Проверка URL

if (
    result.error &&
    result.error.status === 401 &&
    args.url !== '/auth/refresh'
) {

}

Полный безопасный пример

const baseQueryWithReauth = async (
    args,
    api,
    extraOptions
) => {

    let result = await rawBaseQuery(
        args,
        api,
        extraOptions
    )

    if (
        result.error &&
        result.error.status === 401 &&
        args.url !== '/auth/refresh'
    ) {

        const refreshResult = await rawBaseQuery(
            {
                url: '/auth/refresh',
                method: 'POST'
            },
            api,
            extraOptions
        )

        if (refreshResult.data) {

            api.dispatch(
                setCredentials(
                    refreshResult.data
                )
            )

            result = await rawBaseQuery(
                args,
                api,
                extraOptions
            )

        } else {

            api.dispatch(logout())
        }
    }

    return result
}

Централизованный logout

Если refresh token истёк:

POST /refresh -> 401

необходимо:

  • удалить access token;
  • очистить профиль;
  • сбросить кэш;
  • перенаправить пользователя на login.

Сброс кэша RTK Query

RTK Query хранит кэш запросов внутри API slice.

При logout желательно очищать его полностью.

import { api } from './api'

api.dispatch(api.util.resetApiState())

Logout после refresh failure

if (!refreshResult.data) {

    api.dispatch(logout())

    api.dispatch(
        apiSlice.util.resetApiState()
    )
}

Обработка network error

Иногда сервер вообще не отвечает.

Например:

  • отсутствует интернет;
  • сервер недоступен;
  • reverse proxy упал;
  • timeout.

Тогда 401 отсутствует.

Пример ошибки:

{
    status: 'FETCH_ERROR',
    error: 'TypeError: Failed to fetch'
}

Разделение 401 и сетевых ошибок

if (result.error) {

    if (result.error.status === 401) {

        console.log('Unauthorized')
    }

    if (result.error.status === 'FETCH_ERROR') {

        console.log('Network error')
    }
}

Поведение при 403

403 Forbidden отличается от 401.

401

Пользователь не авторизован

403

Пользователь авторизован,
но не имеет доступа

Refresh token при 403 обычно не используется.


Типизация ошибок

В TypeScript RTK Query использует:

FetchBaseQueryError

Пример:

import {
    FetchBaseQueryError
} from '@reduxjs/toolkit/query'

Проверка:

if (
    (result.error as FetchBaseQueryError)
        ?.status === 401
) {

}

Использование кастомного baseQuery в createApi

export const api = createApi({
    reducerPath: 'api',

    baseQuery: baseQueryWithReauth,

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users'
        })
    })
})

Теперь все endpoints автоматически поддерживают:

  • refresh token;
  • retry после 401;
  • logout;
  • mutex;
  • централизованную авторизацию.

Обработка refresh token в localStorage

Иногда оба токена хранятся в localStorage.

Пример:

localStorage.setItem(
    'accessToken',
    token
)

Однако такой подход менее безопасен из-за:

  • XSS-атак;
  • доступа JavaScript к refresh token;
  • возможности кражи токенов.

Более безопасная архитектура

Наиболее распространённая схема:

Токен Хранилище
access token Redux memory
refresh token HttpOnly cookie

Такой подход:

  • снижает риск XSS;
  • скрывает refresh token от JavaScript;
  • упрощает logout;
  • повышает безопасность сессии.

Использование retry вместе с 401

RTK Query предоставляет встроенный retry wrapper.

import {
    retry
} from '@reduxjs/toolkit/query/react'

Retry wrapper

const staggeredBaseQuery = retry(
    baseQueryWithReauth,
    {
        maxRetries: 3
    }
)

Почему retry может быть опасен

Без фильтрации retry способен:

  • повторять refresh-запросы;
  • многократно вызывать logout;
  • создавать DDOS на backend;
  • дублировать mutations.

Особенно опасны:

POST
PATCH
DELETE

Исключение 401 из retry

retry.fail(result.error)

Пример selective retry

const baseQueryWithRetry = retry(

    async (args, api, extraOptions) => {

        const result = await baseQueryWithReauth(
            args,
            api,
            extraOptions
        )

        if (
            result.error &&
            result.error.status === 401
        ) {

            retry.fail(result.error)
        }

        return result
    },

    {
        maxRetries: 3
    }
)

Поведение mutations после refresh

RTK Query повторяет исходный запрос полностью.

Для query это обычно безопасно.

Для mutation могут возникнуть проблемы:

POST /orders

Если сервер обработал запрос, но клиент получил 401 позже, повтор может создать:

  • дубли заказов;
  • повторные платежи;
  • повторные транзакции.

Idempotency Key

Для критических mutation используется:

Idempotency-Key

Пример:

headers.set(
    'Idempotency-Key',
    crypto.randomUUID()
)

Сервер предотвращает повторную обработку одинаковых операций.


Логирование 401 ошибок

Очень полезно централизованное логирование.

if (result.error?.status === 401) {

    console.error({
        url: args.url,
        time: Date.now()
    })
}

Перенаправление после logout

Часто logout должен вызывать redirect:

window.location.href = '/login'

Но внутри baseQuery прямой redirect иногда создаёт проблемы:

  • циклические переходы;
  • race condition;
  • потерю состояния React Router.

Более безопасный подход

Лучше хранить:

isAuthenticated = false

а redirect выполнять на уровне React-компонентов.


Интеграция с React Router

const isAuthenticated = useSelector(
    state => state.auth.accessToken
)

if (!isAuthenticated) {
    return <Navigate to="/login" replace />
}

Архитектурное разделение

Хорошая структура проекта:

src/
├── app/
├── store/
├── services/
│   ├── api/
│   │   ├── baseQuery.js
│   │   ├── apiSlice.js
│   │   └── authApi.js
├── features/
│   ├── auth/
│   └── users/

Вынос refresh логики в отдельную функцию

Крупные проекты часто разделяют код.

const refreshToken = async (
    api,
    extraOptions
) => {

    return await rawBaseQuery(
        {
            url: '/auth/refresh',
            method: 'POST'
        },
        api,
        extraOptions
    )
}

Итоговая схема обработки 401

Полный production-flow обычно выглядит так:

Request
   ↓
401
   ↓
Mutex Lock
   ↓
Refresh Token
   ↓
Save New Access Token
   ↓
Retry Original Request
   ↓
Success

При ошибке refresh:

401
   ↓
Refresh Failed
   ↓
Logout
   ↓
Reset API Cache
   ↓
Redirect To Login