up
This commit is contained in:
@@ -19,13 +19,21 @@ test('журнал сохраняет только разрешённые пол
|
||||
log.record('output-close', { reason: 'renderer', password: 'SECRET', error: new Error('SECRET') })
|
||||
log.record('output-close', { reason: 'https://secret.example/SECRET' })
|
||||
log.record('package', { action: 'download', status: 'failed', response: 'SECRET' })
|
||||
log.record('request-error', {
|
||||
path: '/api/games/{id}/music',
|
||||
reason: 'ENOTFOUND',
|
||||
token: 'SECRET',
|
||||
error: new Error('SECRET'),
|
||||
})
|
||||
log.record('request-error', { path: '/api/games/7?token=SECRET', reason: 'ENOTFOUND' })
|
||||
log.record('request-error', { path: '/api/games/{id}', reason: 'SECRET' })
|
||||
const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
|
||||
assert.equal(content.includes('SECRET'), false)
|
||||
const lines = content
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((s) => JSON.parse(s))
|
||||
assert.equal(lines.length, 3)
|
||||
assert.equal(lines.length, 4)
|
||||
assert.equal(lines[0].version, '0.2.0-rc.1')
|
||||
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NETWORK_ERROR_CODES } from './request-error.js'
|
||||
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -12,6 +13,7 @@ type Event =
|
||||
| 'media-error'
|
||||
| 'connection'
|
||||
| 'package'
|
||||
| 'request-error'
|
||||
const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
|
||||
'output-close': {
|
||||
reason: [
|
||||
@@ -25,6 +27,10 @@ const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
|
||||
'unresponsive',
|
||||
],
|
||||
},
|
||||
'request-error': {
|
||||
path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music'],
|
||||
reason: [...NETWORK_ERROR_CODES, 'AUTH', 'HTTP', 'INVALID_JSON', 'SESSION_CHANGED'],
|
||||
},
|
||||
'media-error': { channel: ['main', 'music', 'scene'] },
|
||||
connection: { status: ['disabled', 'waiting', 'connected', 'offline', 'expired'] },
|
||||
package: { action: ['download', 'check', 'remove'], status: ['success', 'failed'] },
|
||||
@@ -40,6 +46,7 @@ const events: readonly string[] = [
|
||||
'media-error',
|
||||
'connection',
|
||||
'package',
|
||||
'request-error',
|
||||
]
|
||||
export class DiagnosticLog {
|
||||
private disabled = false
|
||||
|
||||
+15
-8
@@ -290,14 +290,21 @@ app
|
||||
}
|
||||
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||
await packages.initialize()
|
||||
online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, {
|
||||
available: () =>
|
||||
safeStorage.isEncryptionAvailable() &&
|
||||
(process.platform !== 'linux' ||
|
||||
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
|
||||
encrypt: (value) => safeStorage.encryptString(value),
|
||||
decrypt: (value) => safeStorage.decryptString(value),
|
||||
})
|
||||
online = new Online(
|
||||
join(app.getPath('userData'), 'connection'),
|
||||
packages,
|
||||
controller,
|
||||
{
|
||||
available: () =>
|
||||
safeStorage.isEncryptionAvailable() &&
|
||||
(process.platform !== 'linux' ||
|
||||
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
|
||||
encrypt: (value) => safeStorage.encryptString(value),
|
||||
decrypt: (value) => safeStorage.decryptString(value),
|
||||
},
|
||||
undefined,
|
||||
(failure) => diagnostics?.record('request-error', failure),
|
||||
)
|
||||
await online.initialize()
|
||||
let previousConnection = online.state.connection
|
||||
const logConnection = () => {
|
||||
|
||||
+49
-1
@@ -35,6 +35,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
}
|
||||
const model = {
|
||||
offline: false,
|
||||
server: 'https://server.test',
|
||||
introURL: 'https://media.test/intro.wav',
|
||||
musicOverride: null as Record<string, unknown> | null,
|
||||
mediaHandler: null as ((url: string) => Promise<Response>) | null,
|
||||
@@ -82,7 +83,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
})
|
||||
}
|
||||
assert.equal(url.origin, 'https://server.test')
|
||||
assert.equal(url.origin, model.server)
|
||||
if (path === '/api/auth/login')
|
||||
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
|
||||
if (path === '/api/auth/refresh') {
|
||||
@@ -264,6 +265,44 @@ async function fixture(t: TestContext, secure = true) {
|
||||
prepare,
|
||||
}
|
||||
}
|
||||
test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => {
|
||||
const f = await fixture(t)
|
||||
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
|
||||
})
|
||||
|
||||
for (const [input, expected] of [
|
||||
['https://evening-detective.crabs-games.art/', 'https://evening-detective-api.crabs-games.art'],
|
||||
['http://127.0.0.1:8090', 'http://127.0.0.1:8090'],
|
||||
]) {
|
||||
test(`адрес входа ${input}: API и сохранённый адрес ${expected}`, async (t) => {
|
||||
const f = await fixture(t, false)
|
||||
f.model.server = expected!
|
||||
await f.online.act({
|
||||
action: 'login',
|
||||
server: input!,
|
||||
email: 'test@example.test',
|
||||
password: 'PASSWORD-SECRET',
|
||||
})
|
||||
assert.equal(f.online.snapshot().authenticated, true)
|
||||
assert.equal(f.online.snapshot().server, expected)
|
||||
assert.ok(f.requests.some((r) => r.path === '/api/auth/login'))
|
||||
await f.online.close()
|
||||
const restored = new Online(
|
||||
join(f.directory, 'connection'),
|
||||
f.store,
|
||||
new Controller(() => {}),
|
||||
f.secrets,
|
||||
f.fetchAPI,
|
||||
)
|
||||
try {
|
||||
await restored.initialize()
|
||||
assert.equal(restored.snapshot().server, expected)
|
||||
} finally {
|
||||
await restored.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function objectMusic(version: number, index = 0) {
|
||||
return {
|
||||
stateVersion: String(version),
|
||||
@@ -1189,3 +1228,12 @@ test('публикация текста после смены игры не от
|
||||
assert.equal(f.controller.state.catalog.key, null)
|
||||
assert.equal(f.controller.state.scene.stepIndex, null)
|
||||
})
|
||||
|
||||
test('истёкшая авторизация при подключении игры не маскируется сетевой ошибкой', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.login()
|
||||
f.model.accessExpired = true
|
||||
f.model.refreshExpired = true
|
||||
await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/)
|
||||
assert.equal(f.online.snapshot().authenticated, false)
|
||||
})
|
||||
|
||||
+22
-27
@@ -1,6 +1,7 @@
|
||||
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
|
||||
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../src/types/online.js'
|
||||
import type {
|
||||
Intent,
|
||||
PlanStatus,
|
||||
@@ -39,20 +40,6 @@ interface Credentials {
|
||||
refresh: string
|
||||
screen: ScreenSession | null
|
||||
}
|
||||
class NetworkError extends Error {}
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number) {
|
||||
super(
|
||||
status === 401
|
||||
? 'Сессия истекла. Войдите снова'
|
||||
: status === 403
|
||||
? 'Нет доступа к действию'
|
||||
: status === 400
|
||||
? 'Действие пока недоступно для этой игры'
|
||||
: `Ошибка сервера: HTTP ${status}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
function serverURL(value: unknown): string {
|
||||
const url = new URL(text(value))
|
||||
if (
|
||||
@@ -64,7 +51,9 @@ function serverURL(value: unknown): string {
|
||||
(url.pathname !== '/' && url.pathname !== '')
|
||||
)
|
||||
throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров')
|
||||
return url.origin
|
||||
return url.origin === DEFAULT_SERVER
|
||||
? 'https://evening-detective-api.crabs-games.art'
|
||||
: url.origin
|
||||
}
|
||||
function token(value: unknown): string {
|
||||
const s = text(value)
|
||||
@@ -74,7 +63,7 @@ function token(value: unknown): string {
|
||||
}
|
||||
export class Online {
|
||||
readonly state: OnlineState = {
|
||||
server: '',
|
||||
server: DEFAULT_SERVER,
|
||||
authenticated: false,
|
||||
remembered: false,
|
||||
busy: false,
|
||||
@@ -119,6 +108,7 @@ export class Online {
|
||||
gameId,
|
||||
token: this.credentials.access,
|
||||
signal: this.materialAbort.signal,
|
||||
onRequestFailure: this.onRequestFailure,
|
||||
},
|
||||
(url, init) => {
|
||||
const parsed = new URL(String(url))
|
||||
@@ -489,7 +479,7 @@ export class Online {
|
||||
body: JSON.stringify(plan),
|
||||
}),
|
||||
)
|
||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
} finally {
|
||||
this.planBusy--
|
||||
@@ -839,6 +829,9 @@ export class Online {
|
||||
private controller: Controller,
|
||||
private secrets: SecretStorage,
|
||||
private fetchAPI: typeof fetch = fetch,
|
||||
private onRequestFailure: NonNullable<
|
||||
import('./screen-package.js').PackageRequest['onRequestFailure']
|
||||
> = () => {},
|
||||
) {
|
||||
controller.onMusicEnded = (report) => {
|
||||
if (!this.screen) return false
|
||||
@@ -1000,18 +993,19 @@ export class Online {
|
||||
this.state.connection = 'disabled'
|
||||
}
|
||||
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const signal = AbortSignal.any([
|
||||
AbortSignal.timeout(30_000),
|
||||
...(init.signal ? [init.signal] : []),
|
||||
])
|
||||
try {
|
||||
return await this.fetchAPI(`${server}${path}`, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
credentials: 'omit',
|
||||
signal: AbortSignal.any([
|
||||
AbortSignal.timeout(30_000),
|
||||
...(init.signal ? [init.signal] : []),
|
||||
]),
|
||||
signal,
|
||||
})
|
||||
} catch {
|
||||
throw new NetworkError('Сервер недоступен или превышено время ожидания')
|
||||
} catch (error) {
|
||||
throw networkFailure(error, signal)
|
||||
}
|
||||
}
|
||||
private async json(response: Response): Promise<Record<string, unknown>> {
|
||||
@@ -1032,7 +1026,7 @@ export class Online {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: c.refresh }),
|
||||
})
|
||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
if (response.status === 401) {
|
||||
this.credentials = null
|
||||
this.state.authenticated = false
|
||||
@@ -1040,7 +1034,7 @@ export class Online {
|
||||
throw new ApiError(401)
|
||||
}
|
||||
const result = await this.json(response)
|
||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
this.credentials = {
|
||||
...c,
|
||||
access: token(result.accessToken),
|
||||
@@ -1062,7 +1056,7 @@ export class Online {
|
||||
return this.raw(c.server, path, { ...init, headers })
|
||||
}
|
||||
let response = await request(c.access)
|
||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel()
|
||||
await this.refresh(c.access, generation)
|
||||
@@ -1195,6 +1189,7 @@ export class Online {
|
||||
gameId,
|
||||
token: this.credentials.access,
|
||||
signal: this.cancellation.signal,
|
||||
onRequestFailure: this.onRequestFailure,
|
||||
},
|
||||
(url, init) => {
|
||||
const parsed = new URL(String(url))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { networkFailure } from './request-error.js'
|
||||
|
||||
test('безопасная причина соединения извлекается из вложенных Node-ошибок', () => {
|
||||
for (const [code, message] of [
|
||||
['ECONNREFUSED', 'Сервер отклонил соединение'],
|
||||
['ECONNRESET', 'Соединение с сервером прервано'],
|
||||
['UND_ERR_CONNECT_TIMEOUT', 'Превышено время ожидания соединения с сервером'],
|
||||
['CERT_HAS_EXPIRED', 'Ошибка защищённого соединения с сервером'],
|
||||
['SECRET', 'Сервер недоступен'],
|
||||
]) {
|
||||
const error = new TypeError('SECRET', {
|
||||
cause: new AggregateError([Object.assign(new Error('SECRET'), { code })]),
|
||||
})
|
||||
const result = networkFailure(error, new AbortController().signal)
|
||||
assert.equal(result.message, message)
|
||||
assert.equal(result.code, code === 'SECRET' ? 'NETWORK' : code)
|
||||
assert.ok(!result.message.includes('SECRET'))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
export const NETWORK_ERROR_CODES = [
|
||||
'NETWORK',
|
||||
'TIMEOUT',
|
||||
'ABORTED',
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'UND_ERR_CONNECT_TIMEOUT',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
] as const
|
||||
export type NetworkErrorCode = (typeof NETWORK_ERROR_CODES)[number]
|
||||
export class NetworkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: NetworkErrorCode = 'NETWORK',
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
export class SessionChangedError extends Error {
|
||||
constructor() {
|
||||
super('Сессия изменилась. Повторите подключение игры')
|
||||
}
|
||||
}
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number) {
|
||||
super(
|
||||
status === 401
|
||||
? 'Сессия истекла. Войдите снова'
|
||||
: status === 403
|
||||
? 'Нет доступа к действию'
|
||||
: status === 400
|
||||
? 'Действие пока недоступно для этой игры'
|
||||
: `Ошибка сервера: HTTP ${status}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function causeCode(error: unknown, depth = 0): NetworkErrorCode | undefined {
|
||||
if (!error || typeof error !== 'object' || depth > 3) return
|
||||
const value = error as { code?: unknown; cause?: unknown; errors?: unknown[] }
|
||||
if (NETWORK_ERROR_CODES.includes(value.code as NetworkErrorCode))
|
||||
return value.code as NetworkErrorCode
|
||||
return (
|
||||
causeCode(value.cause, depth + 1) ??
|
||||
(Array.isArray(value.errors)
|
||||
? value.errors.map((e) => causeCode(e, depth + 1)).find(Boolean)
|
||||
: undefined)
|
||||
)
|
||||
}
|
||||
export function networkFailure(error: unknown, signal: AbortSignal): NetworkError {
|
||||
if (signal.aborted) {
|
||||
return signal.reason?.name === 'TimeoutError'
|
||||
? new NetworkError('Превышено время ожидания ответа сервера', 'TIMEOUT')
|
||||
: new NetworkError('Подготовка отменена', 'ABORTED')
|
||||
}
|
||||
if (error instanceof NetworkError) return error
|
||||
const code = causeCode(error) ?? 'NETWORK'
|
||||
const message =
|
||||
code === 'ENOTFOUND' || code === 'EAI_AGAIN'
|
||||
? 'Не удалось определить адрес сервера'
|
||||
: code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
|
||||
? 'Ошибка защищённого соединения с сервером'
|
||||
: code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT'
|
||||
? 'Превышено время ожидания соединения с сервером'
|
||||
: code === 'ECONNREFUSED'
|
||||
? 'Сервер отклонил соединение'
|
||||
: code === 'ECONNRESET'
|
||||
? 'Соединение с сервером прервано'
|
||||
: 'Сервер недоступен'
|
||||
return new NetworkError(message, code)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { ApiError, SessionChangedError } from './request-error.js'
|
||||
import { buildScreenPackage, prepareScreenPackage } from './screen-package.js'
|
||||
|
||||
const server = 'https://example.test'
|
||||
@@ -200,7 +201,7 @@ test('отказы API и некорректный JSON не выдают час
|
||||
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
|
||||
throw new Error('SECRET')
|
||||
}),
|
||||
/^Error: Сервер недоступен$/,
|
||||
/^Error: Описание игры: Сервер недоступен$/,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -236,3 +237,69 @@ test('отмена и недопустимые адреса обрабатыва
|
||||
)
|
||||
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/)
|
||||
})
|
||||
|
||||
test('сетевая ошибка называет запрос и журналирует только безопасную причину', async () => {
|
||||
for (const suffix of ['', '/screen-plan', '/music']) {
|
||||
const failures: { path: string; reason: string }[] = []
|
||||
const data = fixture()
|
||||
await assert.rejects(
|
||||
prepareScreenPackage(
|
||||
{ server, gameId: 7, token: 'SECRET', onRequestFailure: (f) => failures.push(f) },
|
||||
async (input) => {
|
||||
const url = String(input)
|
||||
if (url === `${server}/api/games/7${suffix}`)
|
||||
throw new TypeError('SECRET', {
|
||||
cause: Object.assign(new Error('SECRET'), { code: 'ENOTFOUND' }),
|
||||
})
|
||||
return Response.json(
|
||||
url.endsWith('/music')
|
||||
? data.music
|
||||
: url.endsWith('/screen-plan')
|
||||
? data.plan
|
||||
: data.game,
|
||||
)
|
||||
},
|
||||
),
|
||||
new RegExp(
|
||||
`^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'}: Не удалось определить адрес сервера$`,
|
||||
),
|
||||
)
|
||||
assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }])
|
||||
assert.ok(!JSON.stringify(failures).includes('SECRET'))
|
||||
}
|
||||
})
|
||||
|
||||
test('подготовка сохраняет ошибки авторизации и смены сессии', async () => {
|
||||
for (const error of [new ApiError(401), new ApiError(403), new SessionChangedError()]) {
|
||||
const data = fixture()
|
||||
await assert.rejects(
|
||||
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/screen-plan')) throw error
|
||||
return Response.json(url.endsWith('/music') ? data.music : data.game)
|
||||
}),
|
||||
(e) => e === error,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('таймаут и отмена подготовки различаются без утечки причины', async () => {
|
||||
for (const [name, message, reason] of [
|
||||
['TimeoutError', 'Превышено время ожидания ответа сервера', 'TIMEOUT'],
|
||||
['AbortError', 'Подготовка отменена', 'ABORTED'],
|
||||
]) {
|
||||
const signal = AbortSignal.abort(new DOMException('SECRET', name))
|
||||
const failures: { reason: string }[] = []
|
||||
await assert.rejects(
|
||||
prepareScreenPackage(
|
||||
{ server, gameId: 7, token: 'SECRET', signal, onRequestFailure: (f) => failures.push(f) },
|
||||
async (_input, init) => {
|
||||
init?.signal?.throwIfAborted()
|
||||
return Response.json({})
|
||||
},
|
||||
),
|
||||
new RegExp(`^Error: Описание игры: ${message}$`),
|
||||
)
|
||||
assert.ok(failures.every((f) => f.reason === reason))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
ApiError,
|
||||
NetworkError,
|
||||
SessionChangedError,
|
||||
networkFailure,
|
||||
type NetworkErrorCode,
|
||||
} from './request-error.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export interface ScreenPackage {
|
||||
@@ -30,6 +37,10 @@ export interface PackageRequest {
|
||||
gameId: number
|
||||
token: string
|
||||
signal?: AbortSignal
|
||||
onRequestFailure?: (failure: {
|
||||
path: string
|
||||
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
|
||||
}) => void
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
@@ -238,12 +249,35 @@ export async function prepareScreenPackage(
|
||||
redirect: 'error',
|
||||
signal,
|
||||
})
|
||||
} catch {
|
||||
throw new Error(
|
||||
signal.aborted ? 'Подготовка отменена или превышено время ожидания' : 'Сервер недоступен',
|
||||
)
|
||||
} catch (error) {
|
||||
const path = `/api/games/{id}${suffix}`
|
||||
if (error instanceof ApiError || error instanceof SessionChangedError) {
|
||||
request.onRequestFailure?.({
|
||||
path,
|
||||
reason:
|
||||
error instanceof ApiError
|
||||
? [401, 403].includes(error.status)
|
||||
? 'AUTH'
|
||||
: 'HTTP'
|
||||
: 'SESSION_CHANGED',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
const failure = networkFailure(error, signal)
|
||||
request.onRequestFailure?.({ path, reason: failure.code })
|
||||
const name =
|
||||
suffix === '/music'
|
||||
? 'Музыка игры'
|
||||
: suffix === '/screen-plan'
|
||||
? 'План экранов'
|
||||
: 'Описание игры'
|
||||
throw new NetworkError(`${name}: ${failure.message}`, failure.code)
|
||||
}
|
||||
if (!response.ok)
|
||||
if (!response.ok) {
|
||||
request.onRequestFailure?.({
|
||||
path: `/api/games/{id}${suffix}`,
|
||||
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
|
||||
})
|
||||
throw new Error(
|
||||
response.status === 401
|
||||
? 'Требуется авторизация'
|
||||
@@ -251,9 +285,11 @@ export async function prepareScreenPackage(
|
||||
? 'Нет доступа к игре'
|
||||
: `Ошибка API: HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
return await response.json()
|
||||
} catch {
|
||||
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
|
||||
throw new Error('Некорректный JSON в ответе API')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user