Код ответа 401 Unauthorized означает, что сервер не
принимает текущие учетные данные пользователя. В большинстве приложений
это связано с:
В RTK Query перехват таких ошибок обычно реализуется внутри
baseQuery, поскольку именно этот слой отвечает за все
сетевые запросы.
Стандартный поток обработки выглядит следующим образом:
401.Наиболее распространённая архитектура начинается с создания
собственного 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 умеет:
Однако он не содержит встроенной логики:
Поэтому поверх него создаётся обёртка.
Наиболее типичная схема:
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
}
Теперь все ответы проходят через единый промежуточный слой.
После получения 401 необходимо:
/refresh.Пример 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
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
}
Часто refresh token хранится в:
В этом случае браузер не отправляет cookie автоматически без:
credentials: 'include'
Без этой настройки refresh-запрос будет выполняться без cookie, и
сервер всегда вернёт 401.
Ключевой момент:
result = await rawBaseQuery(
args,
api,
extraOptions
)
args содержит оригинальный запрос:
{
url: '/users',
method: 'GET'
}
После обновления токена запрос отправляется повторно уже с новым access token.
Одна из самых опасных ситуаций возникает при множественных запросах.
Например:
/users
/profile
/posts
/notifications
Все запросы одновременно получают 401.
Без защиты приложение выполнит:
POST /refresh
POST /refresh
POST /refresh
POST /refresh
Это приводит к:
Наиболее популярное решение — использование
async-mutex.
Установка:
npm install async-mutex
import { Mutex } from 'async-mutex'
const mutex = new 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
}
Первый запрос получает 401:
GET /users -> 401
Он захватывает mutex:
const release = await mutex.acquire()
После этого остальные запросы блокируются.
Они доходят до:
await mutex.waitForUnlock()
и ожидают завершения refresh-процесса.
Первый запрос:
Очень опасная ошибка:
401 -> refresh -> 401 -> refresh -> 401
Такой цикл способен полностью заблокировать приложение.
Поэтому refresh endpoint никогда не должен повторно запускать refresh.
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
}
Если refresh token истёк:
POST /refresh -> 401
необходимо:
RTK Query хранит кэш запросов внутри API slice.
При logout желательно очищать его полностью.
import { api } from './api'
api.dispatch(api.util.resetApiState())
if (!refreshResult.data) {
api.dispatch(logout())
api.dispatch(
apiSlice.util.resetApiState()
)
}
Иногда сервер вообще не отвечает.
Например:
Тогда 401 отсутствует.
Пример ошибки:
{
status: 'FETCH_ERROR',
error: 'TypeError: Failed to fetch'
}
if (result.error) {
if (result.error.status === 401) {
console.log('Unauthorized')
}
if (result.error.status === 'FETCH_ERROR') {
console.log('Network error')
}
}
403 Forbidden отличается от 401.
Пользователь не авторизован
Пользователь авторизован,
но не имеет доступа
Refresh token при 403 обычно не используется.
В TypeScript RTK Query использует:
FetchBaseQueryError
Пример:
import {
FetchBaseQueryError
} from '@reduxjs/toolkit/query'
Проверка:
if (
(result.error as FetchBaseQueryError)
?.status === 401
) {
}
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
})
})
})
Теперь все endpoints автоматически поддерживают:
Иногда оба токена хранятся в localStorage.
Пример:
localStorage.setItem(
'accessToken',
token
)
Однако такой подход менее безопасен из-за:
Наиболее распространённая схема:
| Токен | Хранилище |
|---|---|
| access token | Redux memory |
| refresh token | HttpOnly cookie |
Такой подход:
RTK Query предоставляет встроенный retry wrapper.
import {
retry
} from '@reduxjs/toolkit/query/react'
const staggeredBaseQuery = retry(
baseQueryWithReauth,
{
maxRetries: 3
}
)
Без фильтрации retry способен:
Особенно опасны:
POST
PATCH
DELETE
retry.fail(result.error)
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
}
)
RTK Query повторяет исходный запрос полностью.
Для query это обычно безопасно.
Для mutation могут возникнуть проблемы:
POST /orders
Если сервер обработал запрос, но клиент получил 401
позже, повтор может создать:
Для критических mutation используется:
Idempotency-Key
Пример:
headers.set(
'Idempotency-Key',
crypto.randomUUID()
)
Сервер предотвращает повторную обработку одинаковых операций.
Очень полезно централизованное логирование.
if (result.error?.status === 401) {
console.error({
url: args.url,
time: Date.now()
})
}
Часто logout должен вызывать redirect:
window.location.href = '/login'
Но внутри baseQuery прямой redirect иногда создаёт проблемы:
Лучше хранить:
isAuthenticated = false
а redirect выполнять на уровне React-компонентов.
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/
Крупные проекты часто разделяют код.
const refreshToken = async (
api,
extraOptions
) => {
return await rawBaseQuery(
{
url: '/auth/refresh',
method: 'POST'
},
api,
extraOptions
)
}
Полный 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