Unify Harbor error handling across server and client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s

This commit is contained in:
2026-07-11 20:38:41 +03:00
parent 457dd912d1
commit 9da4fef1f0
16 changed files with 493 additions and 100 deletions

View File

@@ -0,0 +1,22 @@
# Harbor error contract v1
Public API failures use one envelope:
```json
{
"success": false,
"error": {
"code": "PROVIDER_UNAVAILABLE",
"message": "Провайдер подписки временно недоступен.",
"retryable": true,
"correlationId": "6f1a63de-30f9-4dc5-b8ce-38d38c164fe3",
"details": "HTTP 503"
}
}
```
`code`, Russian user copy, HTTP status and retry policy come from `src/shared/errors.js`. The browser maps copy and retry behavior by `code`; it does not display server-provided `details`. Unknown failures use `UNKNOWN`, never expose the raw exception, and always receive a correlation reference. Server logs use the same reference and redact complete HTTP(S) URLs.
Errors are local operation results, not canonical state replacements. A failed apply keeps the previous snapshot; in particular, server existence is validated before `desiredServerId` is persisted. Frontend errors are shown beside subscription or connection controls. Only retryable codes expose `Повторить`.
This is a coordinated API change: old frontends do not understand the object-valued `error` field, so frontend and control plane must be deployed together. Persisted files and volumes are unchanged. Rollback is code-only and requires no data migration.

View File

@@ -1,4 +1,5 @@
import http from 'node:http'; import http from 'node:http';
import { HarborError } from '../shared/errors.js';
function request(socketPath, pathname, method = 'GET') { function request(socketPath, pathname, method = 'GET') {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -27,8 +28,15 @@ function request(socketPath, pathname, method = 'GET') {
export function createDataplaneClient(socketPath, send = request) { export function createDataplaneClient(socketPath, send = request) {
let current = { running: false, startedAt: null }; let current = { running: false, startedAt: null };
const update = async (pathname, method) => { const update = async (pathname, method) => {
current = await send(socketPath, pathname, method); try {
return current; current = await send(socketPath, pathname, method);
return current;
} catch (cause) {
if (pathname === '/apply' || pathname === '/restart') {
throw new HarborError('PROCESS_START_FAILED', { cause });
}
throw cause;
}
}; };
return { return {

View File

@@ -1,5 +1,6 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { HarborError } from '../shared/errors.js';
const NONCE_RE = /^[a-f0-9]{32}$/; const NONCE_RE = /^[a-f0-9]{32}$/;
const PROOF_RE = /^[a-f0-9]{64}$/; const PROOF_RE = /^[a-f0-9]{64}$/;
@@ -51,9 +52,7 @@ function presenceProof(subscriptionUrl, nonce, gatewayId) {
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) { export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) {
if (!NONCE_RE.test(String(nonce || ''))) { if (!NONCE_RE.test(String(nonce || ''))) {
const error = new Error('Некорректный nonce'); throw new HarborError('REQUEST_INVALID');
error.statusCode = 400;
throw error;
} }
const subscription = String(subscriptionUrl || '').trim(); const subscription = String(subscriptionUrl || '').trim();

View File

@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
@@ -27,6 +28,7 @@ import {
normalizeStoredState, normalizeStoredState,
withStateV0Compatibility, withStateV0Compatibility,
} from '../shared/contracts/state.js'; } from '../shared/contracts/state.js';
import { HarborError, normalizeHarborError } from '../shared/errors.js';
const MAX_BODY_BYTES = 1_000_000; const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
@@ -91,10 +93,11 @@ async function withOperation(kind, operation) {
updateStoredState((state) => state); updateStoredState((state) => state);
return result; return result;
} catch (error) { } catch (error) {
const harborError = normalizeHarborError(error);
operationState = { operationState = {
...operationState, ...operationState,
status: 'failed', status: 'failed',
error: error?.message || String(error), error: harborError.message,
}; };
updateStoredState((state) => state); updateStoredState((state) => state);
throw error; throw error;
@@ -103,7 +106,8 @@ async function withOperation(kind, operation) {
function serializeControl(operation) { function serializeControl(operation) {
const result = controlOperation.then(operation, operation); const result = controlOperation.then(operation, operation);
controlOperation = result.catch(() => {}); // The caller observes result; this settled tail only keeps the next operation runnable.
controlOperation = result.then(() => undefined, () => undefined);
return result; return result;
} }
@@ -112,6 +116,29 @@ function sendJson(res, statusCode, payload) {
res.end(JSON.stringify(payload)); res.end(JSON.stringify(payload));
} }
function redactLogDetails(value) {
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
}
function sendError(res, error) {
const harborError = normalizeHarborError(error);
const correlationId = crypto.randomUUID();
const technical = harborError.cause?.message || harborError.details || error;
console.error(
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`,
);
return sendJson(res, harborError.status, {
success: false,
error: {
code: harborError.code,
message: harborError.message,
retryable: harborError.retryable,
correlationId,
...(harborError.details ? { details: harborError.details } : {}),
},
});
}
function readBody(req) { function readBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
@@ -122,9 +149,7 @@ function readBody(req) {
size += chunk.length; size += chunk.length;
if (size > MAX_BODY_BYTES) { if (size > MAX_BODY_BYTES) {
tooLarge = true; tooLarge = true;
const error = new Error('Тело запроса слишком большое'); reject(new HarborError('REQUEST_INVALID'));
error.statusCode = 413;
reject(error);
return; return;
} }
chunks.push(chunk); chunks.push(chunk);
@@ -134,10 +159,8 @@ function readBody(req) {
if (!chunks.length) return resolve({}); if (!chunks.length) return resolve({});
try { try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch { } catch (cause) {
const error = new Error('Невалидный JSON в теле запроса'); reject(new HarborError('REQUEST_INVALID', { cause }));
error.statusCode = 400;
reject(error);
} }
}); });
req.on('error', reject); req.on('error', reject);
@@ -312,7 +335,8 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
async function applySelectedServer(selectedTag, { persist = true } = {}) { async function applySelectedServer(selectedTag, { persist = true } = {}) {
const cached = readJson(settings.subscriptionCachePath, null); const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) throw new Error('Сначала загрузите подписку'); if (!cached?.config) throw new HarborError('CONFIG_INVALID');
const nextConfig = buildActiveConfig(cached.config, selectedTag);
if (persist) { if (persist) {
updateStoredState((state) => ({ updateStoredState((state) => ({
@@ -325,7 +349,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
const previousConfig = fs.existsSync(settings.configPath) const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8') ? fs.readFileSync(settings.configPath, 'utf8')
: null; : null;
writeSingboxConfig(buildActiveConfig(cached.config, selectedTag)); writeSingboxConfig(nextConfig);
try { try {
await startSingbox(); await startSingbox();
} catch (error) { } catch (error) {
@@ -348,9 +372,7 @@ function refreshSavedSubscription() {
subscriptionRefreshPromise = (async () => { subscriptionRefreshPromise = (async () => {
const initialState = readJson(settings.statePath, {}); const initialState = readJson(settings.statePath, {});
if (!initialState.subscriptionUrl) { if (!initialState.subscriptionUrl) {
const error = new Error('Подписка не настроена'); throw new HarborError('SUBSCRIPTION_INVALID');
error.statusCode = 400;
throw error;
} }
const subscriptionUrl = initialState.subscriptionUrl; const subscriptionUrl = initialState.subscriptionUrl;
@@ -358,7 +380,7 @@ function refreshSavedSubscription() {
return serializeControl(async () => { return serializeControl(async () => {
const currentState = readJson(settings.statePath, {}); const currentState = readJson(settings.statePath, {});
if (currentState.subscriptionUrl !== subscriptionUrl) { if (currentState.subscriptionUrl !== subscriptionUrl) {
throw new Error('Подписка была изменена во время обновления'); throw new HarborError('STATE_CONFLICT');
} }
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
@@ -498,11 +520,11 @@ async function handleApi(req, res) {
if (req.method === 'POST' && req.url === '/api/gateway-auto') { if (req.method === 'POST' && req.url === '/api/gateway-auto') {
if (settings.appMode !== 'client') { if (settings.appMode !== 'client') {
return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' }); throw new HarborError('REQUEST_INVALID');
} }
const { enabled } = await readBody(req); const { enabled } = await readBody(req);
if (typeof enabled !== 'boolean') { if (typeof enabled !== 'boolean') {
return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' }); throw new HarborError('REQUEST_INVALID');
} }
await withOperation('gateway-auto', () => serializeControl(async () => { await withOperation('gateway-auto', () => serializeControl(async () => {
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled)); await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
@@ -529,7 +551,7 @@ async function handleApi(req, res) {
if (req.method === 'POST' && req.url === '/api/apply') { if (req.method === 'POST' && req.url === '/api/apply') {
const { selectedTag = '' } = await readBody(req); const { selectedTag = '' } = await readBody(req);
const tag = String(selectedTag).trim(); const tag = String(selectedTag).trim();
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' }); if (!tag) throw new HarborError('REQUEST_INVALID');
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag))); await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag)));
return sendState(res, { selectedTag: tag }); return sendState(res, { selectedTag: tag });
} }
@@ -545,9 +567,7 @@ async function handleApi(req, res) {
if (req.method === 'POST' && req.url === '/api/singbox/restart') { if (req.method === 'POST' && req.url === '/api/singbox/restart') {
await withOperation('start', () => serializeControl(async () => { await withOperation('start', () => serializeControl(async () => {
if (!fs.existsSync(settings.configPath)) { if (!fs.existsSync(settings.configPath)) {
const error = new Error('Сначала выберите сервер'); throw new HarborError('CONFIG_INVALID');
error.statusCode = 400;
throw error;
} }
await singboxRuntime.restart(); await singboxRuntime.restart();
updateStoredState((state) => ({ updateStoredState((state) => ({
@@ -559,7 +579,7 @@ async function handleApi(req, res) {
return sendState(res, { singboxRunning: true }); return sendState(res, { singboxRunning: true });
} }
return sendJson(res, 404, { success: false, error: 'Не найдено' }); return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
} }
const mime = { const mime = {
@@ -592,11 +612,7 @@ const server = http.createServer(async (req, res) => {
? await handleApi(req, res) ? await handleApi(req, res)
: serveStatic(req, res); : serveStatic(req, res);
} catch (error) { } catch (error) {
console.error('[control] request failed', error); return sendError(res, error);
return sendJson(res, error.statusCode || 500, {
success: false,
error: error.message || String(error),
});
} }
}); });

View File

@@ -1,6 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { settings } from './config.js'; import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const MIXED_INBOUND = 'mixed-in'; const MIXED_INBOUND = 'mixed-in';
@@ -22,7 +23,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire
const vpnOutbound = directClient const vpnOutbound = directClient
? null ? null
: structuredClone(findOutbound(subscriptionConfig, selectedTag)); : structuredClone(findOutbound(subscriptionConfig, selectedTag));
if (!directClient && !vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`); if (!directClient && !vpnOutbound) throw new HarborError('SERVER_NOT_FOUND');
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) { if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp'; vpnOutbound.packet_encoding = 'xudp';

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { setGatewayInterception } from './gatewayRouting.js'; import { setGatewayInterception } from './gatewayRouting.js';
import { HarborError } from '../shared/errors.js';
export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) { export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) {
let child = null; let child = null;
@@ -44,16 +45,27 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' }); const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' });
if (check.status !== 0) { if (check.status !== 0) {
throw new Error((check.stderr || check.stdout || 'sing-box check failed').trim()); throw new HarborError('CONFIG_INVALID', {
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
});
} }
const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex');
if (!force && child && nextHash === configHash) return state(); if (!force && child && nextHash === configHash) return state();
await stop(); await stop();
const current = spawn('sing-box', ['run', '-c', configPath], { let current;
stdio: ['ignore', 'inherit', 'inherit'], try {
}); current = spawn('sing-box', ['run', '-c', configPath], {
stdio: ['ignore', 'inherit', 'inherit'],
});
await new Promise((resolve, reject) => {
current.once('spawn', resolve);
current.once('error', reject);
});
} catch (cause) {
throw new HarborError('PROCESS_START_FAILED', { cause });
}
child = current; child = current;
configHash = nextHash; configHash = nextHash;
startedAt = new Date().toISOString(); startedAt = new Date().toISOString();
@@ -64,7 +76,7 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
child = null; child = null;
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
throw error; throw new HarborError('PROCESS_START_FAILED', { cause: error });
} }
current.once('exit', () => { current.once('exit', () => {
if (child !== current) return; if (child !== current) return;

View File

@@ -1,6 +1,7 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { settings } from './config.js'; import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
@@ -40,10 +41,15 @@ export function parseUserInfo(headerValue) {
export function parseVlessUrl(rawUrl) { export function parseVlessUrl(rawUrl) {
if (!rawUrl.startsWith('vless://')) { if (!rawUrl.startsWith('vless://')) {
throw new Error('VLESS URL must start with vless://'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
const parsed = new URL(rawUrl); let parsed;
try {
parsed = new URL(rawUrl);
} catch (cause) {
throw new HarborError('SUBSCRIPTION_INVALID', { cause });
}
const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out'); const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out');
const uuid = decodeURIComponent(parsed.username || ''); const uuid = decodeURIComponent(parsed.username || '');
const server = parsed.hostname; const server = parsed.hostname;
@@ -55,11 +61,11 @@ export function parseVlessUrl(rawUrl) {
const flow = parsed.searchParams.get('flow') || ''; const flow = parsed.searchParams.get('flow') || '';
if (!uuid || !server || !serverPort) { if (!uuid || !server || !serverPort) {
throw new Error('VLESS URL misses uuid, host or port'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
if (!publicKey || !shortId) { if (!publicKey || !shortId) {
throw new Error('VLESS REALITY parameters pbk and sid are required'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
return { return {
@@ -111,7 +117,7 @@ export function parseSubscriptionBody(body) {
.filter((line) => line.startsWith('vless://')); .filter((line) => line.startsWith('vless://'));
if (!links.length) { if (!links.length) {
throw new Error('Subscription does not contain JSON config or VLESS links'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
parsedConfig = { parsedConfig = {
@@ -130,7 +136,7 @@ export function parseSubscriptionBody(body) {
})); }));
if (!servers.length) { if (!servers.length) {
throw new Error('No supported proxy outbounds found in subscription'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
return { config: parsedConfig, servers }; return { config: parsedConfig, servers };
@@ -140,21 +146,26 @@ async function requestSubscription(url) {
let parsedUrl; let parsedUrl;
try { try {
parsedUrl = new URL(url); parsedUrl = new URL(url);
} catch { } catch (cause) {
throw new Error('Invalid subscription URL'); throw new HarborError('SUBSCRIPTION_INVALID', { cause });
} }
if (!['http:', 'https:'].includes(parsedUrl.protocol)) { if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Subscription URL must use http or https'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
const response = await fetch(parsedUrl, { let response;
headers: subscriptionHeaders(), try {
redirect: 'follow', response = await fetch(parsedUrl, {
}); headers: subscriptionHeaders(),
redirect: 'follow',
});
} catch (cause) {
throw new HarborError('PROVIDER_UNAVAILABLE', { cause });
}
if (!response.ok) { if (!response.ok) {
throw new Error(`Subscription request failed: HTTP ${response.status}`); throw new HarborError('PROVIDER_UNAVAILABLE', { details: `HTTP ${response.status}` });
} }
return response; return response;

33
src/shared/errors.js Normal file
View File

@@ -0,0 +1,33 @@
export const ERROR_DEFINITIONS = Object.freeze({
CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true },
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false },
SUBSCRIPTION_INVALID: { status: 400, message: 'Ссылка подписки недействительна.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false },
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false },
});
export function errorDefinition(code) {
return ERROR_DEFINITIONS[code] || ERROR_DEFINITIONS.UNKNOWN;
}
export class HarborError extends Error {
constructor(code, { cause, details } = {}) {
const definition = errorDefinition(code);
super(definition.message, { cause });
this.name = 'HarborError';
this.code = ERROR_DEFINITIONS[code] ? code : 'UNKNOWN';
this.status = definition.status;
this.retryable = definition.retryable;
this.details = details;
}
}
export function normalizeHarborError(error) {
return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error });
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useReducer, useRef, useState } from 'react'; import React, { useEffect, useReducer, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './styles.css'; import './styles.css';
import { api } from './api.js'; import { api, HarborApiError } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx'; import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx'; import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
import { import {
@@ -18,7 +18,7 @@ function App() {
); );
const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState(null);
const pollGeneration = useRef(0); const pollGeneration = useRef(0);
function setPendingTag(serverId) { function setPendingTag(serverId) {
@@ -60,14 +60,21 @@ function App() {
: '/harbor-connect.svg?v=2'; : '/harbor-connect.svg?v=2';
}, [state?.mode]); }, [state?.mode]);
async function run(action) { async function run(action, context) {
setBusy(true); setBusy(true);
setError(''); setError(null);
try { try {
return await applyMutation(action); return await applyMutation(action);
} catch (err) { } catch (err) {
setError(err.message); const safeError = err instanceof HarborApiError ? err : new HarborApiError();
throw err; setError({
context,
message: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable ? () => run(action, context) : null,
});
return false;
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -91,11 +98,11 @@ function App() {
const data = await api.subscription.fetch(subscriptionUrl); const data = await api.subscription.fetch(subscriptionUrl);
dispatch({ type: 'clear-pending-server' }); dispatch({ type: 'clear-pending-server' });
return data; return data;
}); }, 'subscription');
} }
async function refreshSubscription() { async function refreshSubscription() {
return applyMutation(api.subscription.refresh); return run(api.subscription.refresh, 'subscription');
} }
async function forgetSubscription() { async function forgetSubscription() {
@@ -104,7 +111,7 @@ function App() {
setSubscriptionUrl(''); setSubscriptionUrl('');
dispatch({ type: 'clear-pending-server' }); dispatch({ type: 'clear-pending-server' });
return data; return data;
}); }, 'subscription');
} }
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />; if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
@@ -126,10 +133,10 @@ function App() {
onFetchSubscription={fetchSubscription} onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription} onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription} onForgetSubscription={forgetSubscription}
onApply={(tag) => run(() => api.apply(tag))} onApply={(tag) => run(() => api.apply(tag), 'connection')}
onRestart={() => run(api.singbox.restart)} onRestart={() => run(api.singbox.restart, 'connection')}
onStop={() => run(api.singbox.stop)} onStop={() => run(api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled))} onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled), 'connection')}
/> />
</main> </main>
</div> </div>

View File

@@ -1,16 +1,51 @@
async function request(url, options = {}) { import { ERROR_DEFINITIONS, errorDefinition } from '../shared/errors.js';
const response = await fetch(url, {
...options, export class HarborApiError extends Error {
headers: { constructor(payload = {}, status = 0) {
'content-type': 'application/json', const code = ERROR_DEFINITIONS[payload.code] ? payload.code : 'UNKNOWN';
...(options.headers || {}), const definition = errorDefinition(code);
}, super(definition.message);
}); this.name = 'HarborApiError';
const data = await response.json().catch(() => ({})); this.code = code;
this.status = status >= 400 ? status : definition.status;
this.retryable = definition.retryable;
this.details = payload.details;
this.correlationId = payload.correlationId
|| globalThis.crypto?.randomUUID?.()
|| new Date().toISOString();
}
}
export function validationStatusForError(error) {
return error?.code === 'SUBSCRIPTION_INVALID' ? 'invalid' : 'unavailable';
}
export async function request(url, options = {}, fetchImpl = fetch) {
let response;
try {
response = await fetchImpl(url, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
}
let data = {};
try {
data = await response.json();
} catch {
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
}
if (!response.ok || data?.success === false) { if (!response.ok || data?.success === false) {
const error = new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`); const payload = data?.error && typeof data.error === 'object'
error.status = response.status; ? data.error
throw error; : { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
throw new HarborApiError(payload, response.status);
} }
return data; return data;
} }

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { api } from '../api.js'; import { api, validationStatusForError } from '../api.js';
import { import {
connectionAction, connectionAction,
connectionDurationParts, connectionDurationParts,
@@ -20,6 +20,19 @@ function CloudTooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>; return <span className="client-tooltip" role="tooltip">{children}</span>;
} }
function InlineError({ error, context }) {
if (!error || error.context !== context) return null;
return (
<div className={`client-inline-error is-${context}`} role="alert">
<span>{error.message}</span>
{error.correlationId && (
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
)}
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
</div>
);
}
function InstructionStep({ step }) { function InstructionStep({ step }) {
if (typeof step === 'string') return step; if (typeof step === 'string') return step;
return ( return (
@@ -196,6 +209,7 @@ export function ClientOverviewPage({
}); });
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' }); const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
const [validationAttempt, setValidationAttempt] = useState(0);
const [showIntro, setShowIntro] = useState(!hasSubscription); const [showIntro, setShowIntro] = useState(!hasSubscription);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription); const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [pings, setPings] = useState({}); const [pings, setPings] = useState({});
@@ -240,6 +254,9 @@ export function ClientOverviewPage({
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.status ? subscriptionValidation.status
: normalizedSubscriptionUrl ? 'checking' : 'idle'; : normalizedSubscriptionUrl ? 'checking' : 'idle';
const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.error
: null;
const subscriptionWaiting = hasSubscription && !subscriptionContentReady; const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
useEffect(() => { useEffect(() => {
@@ -316,10 +333,32 @@ export function ClientOverviewPage({
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' }); setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
const timer = setTimeout(() => { const timer = setTimeout(() => {
api.subscription.validate(normalizedSubscriptionUrl, controller.signal) api.subscription.validate(normalizedSubscriptionUrl, controller.signal)
.then(() => setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid' })) .then(() => setSubscriptionValidation({
.catch(() => { url: normalizedSubscriptionUrl,
status: 'valid',
error: null,
}))
.catch((validationError) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'invalid' }); setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: validationStatusForError(validationError),
error: {
context: 'subscription',
message: validationError.message,
correlationId: validationError.correlationId,
retry: validationError.retryable
? () => {
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'checking',
error: null,
});
setValidationAttempt((attempt) => attempt + 1);
}
: null,
},
});
} }
}); });
}, 350); }, 350);
@@ -328,7 +367,7 @@ export function ClientOverviewPage({
clearTimeout(timer); clearTimeout(timer);
controller.abort(); controller.abort();
}; };
}, [normalizedSubscriptionUrl]); }, [normalizedSubscriptionUrl, validationAttempt]);
useEffect(() => { useEffect(() => {
if (!hasSubscription) { if (!hasSubscription) {
@@ -356,7 +395,7 @@ export function ClientOverviewPage({
useEffect(() => { useEffect(() => {
if (!state?.hasSubscription) return undefined; if (!state?.hasSubscription) return undefined;
onRefreshSubscription().catch(() => {}); onRefreshSubscription();
return undefined; return undefined;
}, [state?.hasSubscription]); }, [state?.hasSubscription]);
@@ -413,7 +452,7 @@ export function ClientOverviewPage({
async function submitSubscription(event) { async function submitSubscription(event) {
event.preventDefault(); event.preventDefault();
if (subscriptionValidationStatus !== 'valid') return; if (subscriptionValidationStatus !== 'valid') return;
await onFetchSubscription(); if (!await onFetchSubscription()) return;
setSubscriptionUrl(''); setSubscriptionUrl('');
setEditingSubscription(false); setEditingSubscription(false);
} }
@@ -434,7 +473,7 @@ export function ClientOverviewPage({
const startedAt = performance.now(); const startedAt = performance.now();
setRefreshingInfo(true); setRefreshingInfo(true);
try { try {
await onRefreshSubscription(); if (!await onRefreshSubscription()) return;
setUsageUpdated(false); setUsageUpdated(false);
requestAnimationFrame(() => setUsageUpdated(true)); requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900); setTimeout(() => setUsageUpdated(false), 900);
@@ -451,7 +490,7 @@ export function ClientOverviewPage({
} }
async function forgetSubscription() { async function forgetSubscription() {
await onForgetSubscription(); if (!await onForgetSubscription()) return;
setConfirmingDelete(false); setConfirmingDelete(false);
} }
@@ -641,11 +680,10 @@ export function ClientOverviewPage({
</div> </div>
)} )}
</section> </section>
<InlineError error={error} context="connection" />
</section> </section>
)} )}
{error && <p className="client-error" role="alert">{error}</p>}
<div <div
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}${confirmingDelete ? ' is-confirming-delete' : ''}`} className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}${confirmingDelete ? ' is-confirming-delete' : ''}`}
aria-hidden={subscriptionWaiting} aria-hidden={subscriptionWaiting}
@@ -759,15 +797,20 @@ export function ClientOverviewPage({
? 'Сохранить подписку' ? 'Сохранить подписку'
: subscriptionValidationStatus === 'invalid' : subscriptionValidationStatus === 'invalid'
? 'Ссылка подписки не распознана' ? 'Ссылка подписки не распознана'
: 'Проверяем ссылку подписки'} : subscriptionValidationStatus === 'unavailable'
? 'Проверка подписки временно недоступна'
: 'Проверяем ссылку подписки'}
disabled={busy || subscriptionValidationStatus !== 'valid'} disabled={busy || subscriptionValidationStatus !== 'valid'}
> >
{subscriptionValidationStatus === 'valid' {subscriptionValidationStatus === 'valid'
? '✓' ? '✓'
: subscriptionValidationStatus === 'invalid' ? '×' : '…'} : subscriptionValidationStatus === 'invalid'
? '×'
: subscriptionValidationStatus === 'unavailable' ? '!' : '…'}
</button> </button>
)} )}
</form> </form>
<InlineError error={subscriptionError || error} context="subscription" />
</div> </div>
{hasSubscription && subscriptionContentReady && hasUsage && ( {hasSubscription && subscriptionContentReady && hasUsage && (

View File

@@ -22,7 +22,7 @@ export function compatibleSnapshot(snapshot) {
export function classifySyncError(error) { export function classifySyncError(error) {
const status = Number(error?.status) || 0; const status = Number(error?.status) || 0;
if (error?.code === 'INCOMPATIBLE_API' || status === 404) return 'incompatible-api'; if (error?.code === 'INCOMPATIBLE_API' || status === 404) return 'incompatible-api';
if (error?.name === 'TypeError' || status >= 500) return 'control-unreachable'; if (error?.code === 'CONTROL_UNREACHABLE' || error?.name === 'TypeError' || status >= 500) return 'control-unreachable';
return 'fatal'; return 'fatal';
} }

View File

@@ -876,6 +876,7 @@ p {
} }
.client-power-section { .client-power-section {
position: relative;
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 18px; gap: 18px;
@@ -1896,17 +1897,53 @@ p {
margin-top: 7px; margin-top: 7px;
} }
.client-error { .client-inline-error {
position: absolute; position: absolute;
bottom: 0;
left: 50%; left: 50%;
width: min(420px, 90vw); z-index: 3;
width: min(360px, 90vw);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
color: oklch(0.62 0.2 28); color: oklch(0.62 0.2 28);
font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: center; text-align: center;
transform: translateX(-50%); transform: translateX(-50%);
} }
.client-inline-error.is-connection {
top: calc(100% + 12px);
}
.client-inline-error.is-subscription {
top: calc(100% + 2px);
}
.client-inline-error small {
color: var(--client-muted);
font-size: 9px;
white-space: nowrap;
}
.client-inline-error button {
padding: 4px 7px;
border: 0;
border-radius: 6px;
background: color-mix(in oklch, var(--client-control) 72%, transparent);
color: var(--client-text);
font-size: 9px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.client-inline-error button:focus-visible {
outline: 2px solid oklch(0.68 0.15 28);
outline-offset: 2px;
}
.client-copy-button { .client-copy-button {
position: relative; position: relative;
width: 86px; width: 86px;
@@ -2009,6 +2046,19 @@ p {
padding: 20px 6px; padding: 20px 6px;
} }
.client-power-section {
padding-bottom: 40px;
}
.client-inline-error.is-connection {
top: auto;
bottom: 0;
}
.client-form-content {
gap: 48px;
}
.harbor-brand { .harbor-brand {
transform: translate(-50%, calc(-50% - 28vh)) scale(2.15); transform: translate(-50%, calc(-50% - 28vh)) scale(2.15);
} }

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ERROR_DEFINITIONS,
HarborError,
normalizeHarborError,
} from '../../src/shared/errors.js';
test('every Harbor error code has stable Russian copy and retry policy', () => {
for (const [code, definition] of Object.entries(ERROR_DEFINITIONS)) {
const error = new HarborError(code);
assert.equal(error.code, code);
assert.equal(error.message, definition.message);
assert.equal(error.retryable, definition.retryable);
assert.match(error.message, /[А-Яа-яЁё]/);
assert.equal(typeof error.status, 'number');
}
});
test('unknown failures use the safe non-retryable fallback', () => {
const error = normalizeHarborError(new Error('secret internal failure'));
assert.equal(error.code, 'UNKNOWN');
assert.equal(error.message, ERROR_DEFINITIONS.UNKNOWN.message);
assert.equal(error.retryable, false);
assert.equal(error.message.includes('secret'), false);
});

View File

@@ -29,13 +29,18 @@ async function freePort() {
return port; return port;
} }
async function request(port, pathname, method = 'GET', body) { async function rawRequest(port, pathname, method = 'GET', body) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}); });
const payload = await response.json(); const payload = await response.json();
return { response, payload };
}
async function request(port, pathname, method = 'GET', body) {
const { response, payload } = await rawRequest(port, pathname, method, body);
assert.equal(response.ok, true, JSON.stringify(payload)); assert.equal(response.ok, true, JSON.stringify(payload));
return payload; return payload;
} }
@@ -54,7 +59,7 @@ async function waitForState(port, child, stderr) {
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => { test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
const stored = normalizeStoredState({ const stored = normalizeStoredState({
subscriptionUrl: 'https://provider.example/subscription/full-secret-token', subscriptionUrl: 'https://provider.example/subscription/test',
selectedTag: ' legacy ', selectedTag: ' legacy ',
servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }], servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }],
}); });
@@ -95,14 +100,20 @@ test('GET and domain mutations return one state shape with monotonic revisions',
}], }],
}; };
fs.mkdirSync(binDir); fs.mkdirSync(binDir);
fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node const singboxPath = path.join(binDir, 'sing-box');
const workingSingbox = `#!/usr/bin/env node
if (process.argv[2] === 'check') process.exit(0); if (process.argv[2] === 'check') process.exit(0);
process.on('SIGTERM', () => process.exit(0)); process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000); setInterval(() => {}, 60_000);
`); `;
fs.chmodSync(path.join(binDir, 'sing-box'), 0o755); fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
const subscriptionServer = http.createServer((req, res) => { const subscriptionServer = http.createServer((req, res) => {
if (req.url === '/unavailable') {
res.writeHead(503);
return res.end('unavailable');
}
res.writeHead(200, { res.writeHead(200, {
'content-type': 'application/json', 'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100', 'subscription-userinfo': 'upload=10; download=20; total=100',
@@ -110,7 +121,7 @@ setInterval(() => {}, 60_000);
res.end(JSON.stringify(config)); res.end(JSON.stringify(config));
}); });
const subscriptionPort = await listen(subscriptionServer); const subscriptionPort = await listen(subscriptionServer);
const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/full-secret-token`; const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/test`;
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({ fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
subscriptionUrl, subscriptionUrl,
selectedTag: 'test-vpn', selectedTag: 'test-vpn',
@@ -150,6 +161,42 @@ setInterval(() => {}, 60_000);
const stateKeys = Object.keys(initial).sort(); const stateKeys = Object.keys(initial).sort();
let revision = initial.revision; let revision = initial.revision;
const invalidSubscription = await rawRequest(
port,
'/api/subscription/validate',
'POST',
{ url: 'not-a-url' },
);
assert.equal(invalidSubscription.response.status, 400);
assert.deepEqual(
{
code: invalidSubscription.payload.error.code,
retryable: invalidSubscription.payload.error.retryable,
},
{ code: 'SUBSCRIPTION_INVALID', retryable: false },
);
assert.equal(typeof invalidSubscription.payload.error.correlationId, 'string');
const providerUnavailable = await rawRequest(
port,
'/api/subscription/validate',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}/unavailable` },
);
assert.equal(providerUnavailable.response.status, 502);
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
assert.equal(providerUnavailable.payload.error.retryable, true);
const missingServer = await rawRequest(
port,
'/api/apply',
'POST',
{ selectedTag: 'missing-server' },
);
assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, 'test-vpn');
async function stateResponse(pathname, method = 'POST', body) { async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body); const result = await request(port, pathname, method, body);
assert.deepEqual(Object.keys(result.state).sort(), stateKeys); assert.deepEqual(Object.keys(result.state).sort(), stateKeys);
@@ -174,10 +221,30 @@ setInterval(() => {}, 60_000);
assert.equal(applied.state.connection.process, 'running'); assert.equal(applied.state.connection.process, 'running');
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped'); assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running'); assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
if (process.argv[2] === 'check') {
require('node:fs').unlinkSync(process.argv[1]);
process.exit(0);
}
`);
fs.chmodSync(singboxPath, 0o755);
const processFailure = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(processFailure.response.status, 503);
assert.equal(processFailure.payload.error.code, 'PROCESS_START_FAILED');
assert.equal(processFailure.payload.error.retryable, true);
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
await mutation('/api/subscription/refresh'); await mutation('/api/subscription/refresh');
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false); assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE'); const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing'); assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0); assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []); assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(missingConfig.response.status, 422);
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
assert.equal(missingConfig.payload.error.retryable, false);
}); });

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
HarborApiError,
request,
validationStatusForError,
} from '../../src/web/api.js';
const response = (status, error) => ({
ok: status >= 200 && status < 300,
status,
json: async () => ({ success: false, error }),
});
test('frontend exposes retry only for retryable structured errors', async () => {
await assert.rejects(
request('/api/test', {}, async () => response(502, {
code: 'PROVIDER_UNAVAILABLE',
message: 'untrusted server copy',
retryable: false,
correlationId: 'provider-reference',
})),
(error) => {
assert.ok(error instanceof HarborApiError);
assert.equal(error.message, 'Провайдер подписки временно недоступен.');
assert.equal(error.retryable, true);
assert.equal(error.correlationId, 'provider-reference');
return true;
},
);
await assert.rejects(
request('/api/test', {}, async () => response(400, {
code: 'SUBSCRIPTION_INVALID',
retryable: true,
})),
(error) => error.retryable === false,
);
});
test('network failures become retryable control errors', async () => {
await assert.rejects(
request('/api/state', {}, async () => { throw new TypeError('fetch failed'); }),
(error) => error.code === 'CONTROL_UNREACHABLE' && error.retryable === true,
);
});
test('local unknown errors get a safe message and diagnostic reference', () => {
const error = new HarborApiError({ code: 'NOT_A_REAL_CODE' });
assert.equal(error.code, 'UNKNOWN');
assert.equal(error.message, 'Не удалось выполнить действие.');
assert.equal(typeof error.correlationId, 'string');
assert.ok(error.correlationId.length >= 8);
});
test('subscription validation distinguishes bad input from provider outage', () => {
assert.equal(validationStatusForError({ code: 'SUBSCRIPTION_INVALID' }), 'invalid');
assert.equal(validationStatusForError({ code: 'PROVIDER_UNAVAILABLE' }), 'unavailable');
});