Chybova hlaseni konektoru rikaji, co se stalo, a kam to slo

Duvod od sluzby jde primo do hlasky: z tela odpovedi se vytahne detail,
error_description, message, title, error i seznam missingHeaders. Retezec,
ktery vypada jako JSON, se rozbaluje dal - sluzba iDoklad presne takhle
predava telo od iDokladu samotneho. Kdyz sluzba nenapsala nic, rekne se to.

401 a 403 uz nejsou jedna hlaska. 401 = udaje sluzba dostala a neuznala je.
403 = tvar udaju je v poradku, zakazuje se samo volani.

V kazde hlasce je cela adresa vcetne serveru (ScriptRequestInfo.url),
bez query - v query muze byt tajemstvi. Zaklad adresy je z konfigurace
a konektor ho smi prepsat, takze se neda odvodit z toho, kde je nasazeny
portal. Adresa je videt i na karte konektoru a v odpovedi na test, i kdyz
overeni projde.

Tlacitko Logy na karte konektoru a historie poslednich peti overeni.
Odpoved sluzby dosud existovala jen v odpovedi na test, tedy do prekresleni
stranky, a v logu containeru. Do logu containeru se nikdo divat nechodi.
Zaznam se uklada i pri uspechu, jinak by neslo poznat, jestli konektor nesel
nikdy, nebo prestal jit ve chvili, kdy nekdo sahnul na udaje.

Migrace 003_connector_checks.sql, endpoint GET /connectors/:id/checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-25 07:06:14 +02:00
co-authored by Claude Opus 5
parent 6297bbf480
commit 5a124a53d8
14 changed files with 648 additions and 54 deletions
+5 -2
View File
@@ -25,6 +25,7 @@ import { createLocalConnectors } from './connectors/local.js';
import { postgresConnectors } from './connectors/postgres.js';
import type {
Connector,
ConnectorCheck,
ConnectorRepository,
CreateConnectorInput,
UpdateConnectorInput,
@@ -33,12 +34,13 @@ import { fileSnapshot, memorySnapshot } from './snapshot.js';
export type {
Connector,
ConnectorCheck,
ConnectorIssue,
CreateConnectorInput,
PublicConnector,
UpdateConnectorInput,
} from './connectors/types.js';
export { toPublicConnector, validateConnectorValues } from './connectors/types.js';
export { CHECK_HISTORY, toPublicConnector, validateConnectorValues } from './connectors/types.js';
export type StorageMode = 'postgres' | 'file' | 'memory';
@@ -226,7 +228,8 @@ export function setConnectorStatus(
id: string,
status: Connector['status'],
error: string | null,
check: ConnectorCheck | null,
tenantIds: string[],
): Promise<Connector | undefined> {
return repository.setStatus(id, status, error, tenantIds);
return repository.setStatus(id, status, error, check, tenantIds);
}
+10 -3
View File
@@ -19,6 +19,7 @@ import { openAll, sealAll } from '../../db/secretBox.js';
import { findService } from '../services.js';
import type { SnapshotStore } from '../snapshot.js';
import {
CHECK_HISTORY,
nowIso,
type Connector,
type ConnectorRepository,
@@ -73,7 +74,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
/** Kopie, aby volajici nemohl zmenit stav uloziste zapisem do vysledku. */
function copy(row: Connector): Connector {
return { ...row, values: { ...row.values } };
return { ...row, values: { ...row.values }, checks: [...row.checks] };
}
return {
@@ -84,7 +85,9 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
rows.length = 0;
for (const item of stored) {
const { secrets, ...rest } = item;
rows.push({ ...rest, values: openAll(secrets) });
// Soubor zapsany starsi verzi historii overeni nema. Chybejici pole
// je prazdna historie, ne duvod, proc by uloziste nemelo nastartovat.
rows.push({ ...rest, values: openAll(secrets), checks: rest.checks ?? [] });
}
await seed();
},
@@ -140,6 +143,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
status: 'untested',
lastCheckAt: null,
lastError: null,
checks: [],
// Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit.
isDefault: input.isDefault ?? existing.length === 0,
createdAt: timestamp,
@@ -168,6 +172,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
// Zmena udaju znamena, ze predchozi overeni uz nic nerika.
row.status = 'untested';
row.lastError = null;
row.checks = [];
}
if (patch.isDefault === true) {
@@ -199,12 +204,13 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
return true;
},
async setStatus(id, status, error, tenantIds) {
async setStatus(id, status, error, check, tenantIds) {
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
row.status = status;
row.lastError = error;
if (check) row.checks = [check, ...row.checks].slice(0, CHECK_HISTORY);
row.lastCheckAt = nowIso();
row.updatedAt = row.lastCheckAt;
persist();
@@ -235,6 +241,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
status: 'untested',
lastCheckAt: null,
lastError: null,
checks: [],
isDefault: true,
createdAt: timestamp,
updatedAt: timestamp,
+24 -5
View File
@@ -13,8 +13,10 @@
import { randomUUID } from 'node:crypto';
import { query, queryOne, transaction } from '../../db/pool.js';
import { openAll, sealAll } from '../../db/secretBox.js';
import { CHECK_HISTORY } from './types.js';
import type {
Connector,
ConnectorCheck,
ConnectorRepository,
CreateConnectorInput,
UpdateConnectorInput,
@@ -31,6 +33,7 @@ interface ConnectorRow {
status: string;
last_check_at: Date | null;
last_error: string | null;
checks: unknown;
is_default: boolean;
created_at: Date;
updated_at: Date;
@@ -48,6 +51,7 @@ function toConnector(row: ConnectorRow): Connector {
status: row.status as Connector['status'],
lastCheckAt: row.last_check_at ? row.last_check_at.toISOString() : null,
lastError: row.last_error,
checks: Array.isArray(row.checks) ? (row.checks as ConnectorCheck[]) : [],
isDefault: row.is_default,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
@@ -56,7 +60,7 @@ function toConnector(row: ConnectorRow): Connector {
const COLUMNS = `
id, tenant_id, service_id, name, base_url, secrets, enabled, status,
last_check_at, last_error, is_default, created_at, updated_at
last_check_at, last_error, checks, is_default, created_at, updated_at
`;
export const postgresConnectors: ConnectorRepository = {
@@ -187,6 +191,7 @@ export const postgresConnectors: ConnectorRepository = {
is_default = CASE WHEN $7::boolean THEN true ELSE is_default END,
status = CASE WHEN $8::boolean THEN 'untested' ELSE status END,
last_error = CASE WHEN $8::boolean THEN NULL ELSE last_error END,
checks = CASE WHEN $8::boolean THEN '[]'::jsonb ELSE checks END,
updated_at = now()
WHERE id = $1
RETURNING ${COLUMNS}`,
@@ -234,14 +239,28 @@ export const postgresConnectors: ConnectorRepository = {
});
},
async setStatus(id, status, error, tenantIds) {
/**
* Zapis vysledku overeni.
*
* Historie se skrouhne az v jednom kroku v SQL, ne ctenim a zapisem zvlast:
* dve soubezna overeni tehoz konektoru by si jinak navzajem prepsala
* zaznam a jeden by zmizel.
*/
async setStatus(id, status, error, check, tenantIds) {
if (tenantIds.length === 0) return undefined;
const row = await queryOne<ConnectorRow>(
`UPDATE connectors
SET status = $3, last_error = $4, last_check_at = now(), updated_at = now()
SET status = $3,
last_error = $4,
checks = CASE
WHEN $5::jsonb IS NULL THEN checks
ELSE jsonb_path_query_array($5::jsonb || checks, '$[0 to ${CHECK_HISTORY - 1}]')
END,
last_check_at = now(),
updated_at = now()
WHERE id = $1 AND tenant_id = ANY($2)
RETURNING ${COLUMNS}`,
[id, tenantIds, status, error],
RETURNING ${'${COLUMNS}'}`,
[id, tenantIds, status, error, check ? JSON.stringify([check]) : null],
);
return row ? toConnector(row) : undefined;
},
+44 -2
View File
@@ -10,6 +10,31 @@
import { findService, type Service, type ServiceCredentialField } from '../services.js';
/**
* Jeden zaznam o overeni konektoru.
*
* Drzi se **cele telo odpovedi**, ne jen kod. Prave tam sluzba pise, co ji
* vadilo, a bez toho se neda rozlisit spatny udaj od zakazane IP adresy.
* Text je zredigovany uz pri vzniku, takze pristupovy udaj v nem nikdy neni.
*/
export interface ConnectorCheck {
/** ISO cas overeni. */
at: string;
ok: boolean;
/** Co se overovalo: pristupove udaje, nebo jen dostupnost sluzby. */
checked: string;
/** HTTP kod od sluzby. null, kdyz se k volani vubec nedoslo. */
status: number | null;
message: string;
/** Cele telo odpovedi sluzby. */
detail: string | null;
/** Cela adresa vcetne serveru, bez query. null, kdyz se k volani nedoslo. */
request: { method: string; path: string; url: string } | null;
}
/** Kolik zaznamu o overeni se u konektoru drzi. */
export const CHECK_HISTORY = 5;
export interface Connector {
id: string;
tenantId: string;
@@ -25,6 +50,8 @@ export interface Connector {
status: 'untested' | 'ok' | 'error';
lastCheckAt: string | null;
lastError: string | null;
/** Poslednich `CHECK_HISTORY` overeni, nejnovejsi prvni. */
checks: ConnectorCheck[];
/** Krok stromu bez vybraneho konektoru pouzije vychozi. */
isDefault: boolean;
createdAt: string;
@@ -45,6 +72,12 @@ export interface PublicConnector {
status: Connector['status'];
lastCheckAt: string | null;
lastError: string | null;
/**
* Kolik zaznamu o overeni je k dispozici. Samotna historie se sem nedava,
* v seznamu konektoru by to byly desitky kilobajtu tel odpovedi navic.
* Cte se zvlast pres `/connectors/:id/checks`.
*/
checkCount: number;
isDefault: boolean;
createdAt: string;
updatedAt: string;
@@ -102,6 +135,8 @@ export interface ConnectorRepository {
id: string,
status: Connector['status'],
error: string | null,
/** Zaznam, ktery se zaradi na zacatek historie. null = historii nemenit. */
check: ConnectorCheck | null,
tenantIds: string[],
): Promise<Connector | undefined>;
}
@@ -142,8 +177,15 @@ export function toPublicConnector(connector: Connector): PublicConnector {
const missing = service ? missingFields(service, connector.values) : [];
const { values: _values, ...rest } = connector;
return { ...rest, filled, missing, config, ready: missing.length === 0 };
const { values: _values, checks, ...rest } = connector;
return {
...rest,
checkCount: checks.length,
filled,
missing,
config,
ready: missing.length === 0,
};
}
/**
@@ -0,0 +1,16 @@
-- Historie overeni konektoru, poslednich pet zaznamu.
--
-- Duvod, proc to je sloupec a ne jen odpoved endpointu: hlaska "HTTP 403"
-- nikoho nikam nedovede a to, co ji vysvetluje, je telo odpovedi sluzby.
-- To dosud existovalo jen v odpovedi na test a v logu containeru. Do logu
-- containeru se nikdo divat nechodi, takze to bylo totez jako nikde.
--
-- Pet zaznamu proto, ze to je presne na otazku "co to delalo predtim, nez
-- jsem sahnul na udaje". Delsi historie je uz jina uloha a patri do vlastni
-- tabulky, ne do radku konektoru.
--
-- Texty uvnitr jsou uz zredigovane (src/scripts/util.ts, createRedactor)
-- a zkracene na SCRIPT_ERROR_DETAIL_BYTES, takze tady nemuze byt udaj.
ALTER TABLE connectors
ADD COLUMN IF NOT EXISTS checks jsonb NOT NULL DEFAULT '[]'::jsonb;
+68
View File
@@ -222,6 +222,12 @@ export function buildOpenApiDocument() {
status: { type: 'string', enum: ['untested', 'ok', 'error'] },
lastCheckAt: { type: 'string', format: 'date-time', nullable: true },
lastError: { type: 'string', nullable: true },
checkCount: {
type: 'integer',
description:
'Kolik zaznamu o overeni je v historii. Samotna historie se cte pres ' +
'/api/dashboard/connectors/{id}/checks - v seznamu by to byla tela odpovedi navic.',
},
isDefault: {
type: 'boolean',
description: 'Krok stromu bez vybraneho konektoru pouzije tenhle.',
@@ -1674,6 +1680,68 @@ export function buildOpenApiDocument() {
checked: { type: 'string' },
status: { type: 'integer' },
message: { type: 'string' },
baseUrl: {
type: 'string',
description:
'Kam konektor miri. Vraci se i pri uspechu - zaklad adresy je ' +
'z konfigurace a konektor ho smi prepsat, takze bez nej nerika ' +
'kod odpovedi nic o tom, jestli se to trefilo na spravny stroj.',
},
},
},
},
},
},
'404': { description: 'Konektor neexistuje' },
},
},
},
'/api/dashboard/connectors/{id}/checks': {
get: {
tags: ['Konektory'],
summary: 'Historie overeni konektoru',
description:
'Poslednich pet overeni, nejnovejsi prvni. U neuspechu nese zaznam cele telo ' +
'odpovedi sluzby v poli detail - prave tam sluzba pise, co ji vadilo, a bez ' +
'toho se neda rozlisit spatny udaj od zakazane IP adresy. Texty jsou uz ' +
'zredigovane, pristupovy udaj v nich neni. Historie je zvlast a ne v seznamu ' +
'konektoru proto, ze telo odpovedi byva o rady velikosti vetsi nez zbytek radku.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Zaznamy o overeni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
limit: { type: 'integer' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
at: { type: 'string', format: 'date-time' },
ok: { type: 'boolean' },
checked: { type: 'string' },
status: { type: 'integer', nullable: true },
message: { type: 'string' },
detail: { type: 'string', nullable: true },
request: {
type: 'object',
nullable: true,
description:
'url je cela adresa vcetne serveru, bez query - v query muze byt tajemstvi.',
properties: {
method: { type: 'string' },
path: { type: 'string' },
url: { type: 'string' },
},
},
},
},
},
},
},
},
+89 -18
View File
@@ -19,6 +19,7 @@ import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { accessFor } from '../data/access.js';
import {
CHECK_HISTORY,
connectorCountsByService,
createConnector,
deleteConnector,
@@ -28,6 +29,7 @@ import {
toPublicConnector,
updateConnector,
validateConnectorValues,
type ConnectorCheck,
} from '../data/connectorStore.js';
import {
canSeeService,
@@ -255,6 +257,11 @@ connectorsRouter.delete('/:id', async (req, res) => {
* Vola `verifyPath` sluzby, coz je zamerne **cteci** volani, ktere vyzaduje
* autorizaci. Kdyz ho sluzba nema, overi se jen `/health` - a odpoved to rekne
* nahlas, aby si nikdo nemyslel, ze jsou overene i pristupove udaje.
*
* Ve vysledku je vzdy **cela adresa vcetne serveru**, i kdyz overeni projde.
* Zaklad adresy je z konfigurace (`SERVICES_BASE_URL`) a konektor ho smi
* prepsat, takze "vratilo 403" bez serveru nerika, jestli se to vubec trefilo
* na spravny stroj. Hadat to podle toho, kde je nasazeny portal, nejde.
*/
connectorsRouter.post('/:id/test', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
@@ -269,16 +276,29 @@ connectorsRouter.post('/:id/test', async (req, res) => {
const target = resolveTarget(service.id, connector);
if (!target.ready) {
await setConnectorStatus(connector.id, 'error', target.missing.join(', '), [tenantId]);
return res.json({
ok: false,
checked: 'nic',
message: `Napojení není hotové: ${target.missing.join(', ')}.`,
});
const message = `Napojení není hotové: ${target.missing.join(', ')}.`;
await setConnectorStatus(
connector.id,
'error',
message,
{
at: new Date().toISOString(),
ok: false,
checked: 'nic',
status: null,
message,
detail: null,
request: null,
},
[tenantId],
);
return res.json({ ok: false, checked: 'nic', message, baseUrl: target.baseUrl });
}
const path = service.verifyPath ?? '/health';
const checked = service.verifyPath ? 'přístupové údaje' : 'jen dostupnost služby';
// Zaloha pro pripad, ze se k volani vubec nedoslo a chyba tedy `request` nema.
const verifyUrl = `${target.baseUrl.replace(/\/+$/, '')}${path}`;
const redact = createRedactor(Object.values(target.headers));
const controller = new AbortController();
@@ -295,43 +315,94 @@ connectorsRouter.post('/:id/test', async (req, res) => {
});
const response = await http.get(path);
await setConnectorStatus(connector.id, 'ok', null, [tenantId]);
const message = service.verifyPath
? 'Napojení funguje, přístupové údaje jsou platné.'
: 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.';
const request = { method: 'GET', path, url: verifyUrl };
// Do historie patri i uspech. Bez nej se neda poznat, jestli konektor
// nesel nikdy, nebo prestal jit ve chvili, kdy se sahlo na udaje.
await setConnectorStatus(
connector.id,
'ok',
null,
{
at: new Date().toISOString(),
ok: true,
checked,
status: response.status,
message,
detail: null,
request,
},
[tenantId],
);
return res.json({
ok: true,
checked,
status: response.status,
request: { method: 'GET', path },
message: service.verifyPath
? 'Napojení funguje, přístupové údaje jsou platné.'
: 'Služba odpovídá. Přístupové údaje se tímhle neověřily, služba na to nemá čtecí volání.',
request,
message,
baseUrl: target.baseUrl,
});
} catch (err) {
// Cela odpoved sluzby, ne jen "HTTP 401". Duvod je napsany prave v ni.
const isScriptError = err instanceof ScriptError;
const message = isScriptError ? redact(err.message) : redact(describe(err, 400));
const status = isScriptError ? err.status : undefined;
const detail = isScriptError
? err.detail
? redact(err.detail)
: undefined
: redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes));
await setConnectorStatus(connector.id, 'error', message, [tenantId]);
console.warn(
`[connectors] test ${connector.id} selhal: ${message}` + (detail ? `
${detail}` : ''),
);
const request =
isScriptError && err.request ? err.request : { method: 'GET', path, url: verifyUrl };
const check: ConnectorCheck = {
at: new Date().toISOString(),
ok: false,
checked,
status: status ?? null,
message,
detail: detail ?? null,
request,
};
await setConnectorStatus(connector.id, 'error', message, check, [tenantId]);
console.warn(`[connectors] test ${connector.id} selhal: ${message}`);
// Neuspesne overeni neni chyba API, je to vysledek. Proto 200.
return res.json({
ok: false,
checked,
message,
...(isScriptError && err.status !== undefined ? { status: err.status } : {}),
request: isScriptError && err.request ? err.request : { method: 'GET', path },
...(status !== undefined ? { status } : {}),
request,
baseUrl: target.baseUrl,
...(detail ? { detail } : {}),
});
} finally {
clearTimeout(timer);
}
});
/**
* Historie overeni konektoru.
*
* Zvlast, ne v seznamu konektoru: kazdy zaznam nese cele telo odpovedi sluzby
* a u peti konektoru by to byly desitky kilobajtu, ktere nikdo necte.
* Nacte se az kdyz o to nekdo v portalu opravdu stoji.
*/
connectorsRouter.get('/:id/checks', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const connector = await getConnector(req.params.id, [tenantId]);
if (!connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
if (!serviceOrDeny(req, res, connector.serviceId, tenantId)) return;
return res.json({ items: connector.checks, limit: CHECK_HISTORY });
});
+118 -13
View File
@@ -86,35 +86,135 @@ function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOpti
return url;
}
/**
* Duvod, ktery sluzba napsala do tela odpovedi.
*
* Klice v poradi podle toho, jak konkretni obvykle jsou. `detail` u problem
* details byva cela veta, `error` byva jen kod jako `invalid_client`.
* Nekdo (vcetne nasi sluzby iDoklad) da do `detail` cele JSON tela od te
* skutecne sluzby, proto se retezec, ktery vypada jako JSON, rozbaluje dal.
*/
const reasonKeys = ['detail', 'error_description', 'message', 'Message', 'title', 'error'];
/** Klice se seznamem toho, co konkretne chybelo nebo neproslo. */
const reasonListKeys = ['missingHeaders', 'errors', 'Errors'];
/** Strop na duvod v hlasce. Cele telo zustava v `detail`, tohle je jen veta. */
const reasonBytes = 400;
function reasonFromText(value: string, depth: number): string | null {
const raw = value.trim();
if (raw === '') return null;
// HTML od reverse proxy nebo WAF. Do jednoradkove hlasky se necpe, cela
// stranka zustava v `detail`.
if (raw.startsWith('<')) return null;
if (raw.startsWith('{') || raw.startsWith('[')) {
const nested = reasonFromBody(raw, depth + 1);
return nested ?? truncate(raw, reasonBytes);
}
return truncate(raw, reasonBytes);
}
function reasonFromValue(value: unknown, depth: number): string | null {
if (typeof value === 'string') return reasonFromText(value, depth);
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
const parts = value.map((item) => reasonFromValue(item, depth)).filter(Boolean) as string[];
return parts.length > 0 ? truncate(parts.join('; '), reasonBytes) : null;
}
return null;
}
/**
* Vytahne z tela odpovedi vetu pro uzivatele.
*
* Vraci null, kdyz sluzba nenapsala nic pouzitelneho - to je taky informace
* a hlaska to pak rekne narovinu misto toho, aby to zamlcela.
*/
function reasonFromBody(detail: string | undefined, depth = 0): string | null {
const raw = (detail ?? '').trim();
if (raw === '' || depth > 3) return null;
if (!raw.startsWith('{') && !raw.startsWith('[')) return reasonFromText(raw, depth);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return truncate(raw, reasonBytes);
}
if (Array.isArray(parsed)) return reasonFromValue(parsed, depth);
if (parsed === null || typeof parsed !== 'object') return reasonFromValue(parsed, depth);
const record = parsed as Record<string, unknown>;
const parts: string[] = [];
for (const key of reasonKeys) {
if (!(key in record)) continue;
const part = reasonFromValue(record[key], depth);
// `title` byva jen "Unauthorized", tedy to same, co uz rika HTTP kod.
if (part && !parts.includes(part)) parts.push(part);
if (parts.length > 0) break;
}
for (const key of reasonListKeys) {
const part = reasonFromValue(record[key], depth);
if (part) parts.push(`${key}: ${part}`);
}
if (parts.length === 0) return truncate(raw, reasonBytes);
return truncate(parts.join(' | '), reasonBytes);
}
/**
* Chyba z HTTP kodu.
*
* `detail` je **cele telo odpovedi**, jen zredigovane a zkracene az na velkem
* stropu. Prave tam cizi sluzba pise, co ji vadilo - "HTTP 400" samo o sobe
* nikoho nikam nedovede.
* nikoho nikam nedovede. Duvod z tela se navic vytahne rovnou do hlasky:
* rozbalovaci detail cte az ten, kdo uz vi, ze ma kam kliknout.
*
* 401 a 403 se rozlisuji, protoze **kazdy znamena neco jineho** a rada za ne
* je opacna. 401 = sluzba udaje dostala a odmitla je. 403 = udaje proti sobe
* nema, zakazuje samo volani, tedy typicky nepovolena IP adresa volajiciho
* nebo chybejici opravneni uctu.
*/
function statusError(
status: number,
request: ScriptRequestInfo,
detail: string,
): ScriptError {
const where = `${request.method} ${request.path} vrátilo HTTP ${status}`;
const where = `${request.method} ${request.url} vrátilo HTTP ${status}`;
const reason = reasonFromBody(detail);
const said = reason
? ` Služba odpověděla: ${reason}`
: ' Služba k tomu nenapsala nic, tělo odpovědi je prázdné.';
const options = { status, detail, request };
if (retryableStatuses.has(status)) {
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, options);
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.${said}`, options);
}
if (status === 401 || status === 403) {
if (status === 401) {
return new ScriptError(
'config',
`Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`,
`Přístupové údaje odmítnuty: ${where}. Služba údaje dostala a neuznala je, ` +
`jde tedy o Client ID, Client Secret nebo jejich platnost, ne o IP adresu.${said}`,
options,
);
}
if (status === 403) {
return new ScriptError(
'config',
`Přístup zakázán: ${where}. Tohle není chyba tvaru údajů - služba zakazuje samo volání. ` +
`Nejčastěji nepovolená IP adresa volajícího, chybějící oprávnění účtu ` +
`nebo aplikace, pod kterou se volá.${said}`,
options,
);
}
if (status === 404) {
return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, options);
return new ScriptError('terminal', `Záznam nenalezen: ${where}.${said}`, options);
}
return new ScriptError('terminal', `Volání selhalo: ${where}.`, options);
return new ScriptError('terminal', `Volání selhalo: ${where}.${said}`, options);
}
function transportError(err: unknown, request: ScriptRequestInfo): ScriptError {
@@ -131,7 +231,7 @@ Příčina: ${err.cause.message}` : '';
const detail = truncate(`${name}: ${message}${cause}`, config.errorDetailBytes);
if (name === 'AbortError' || name === 'TimeoutError') {
return new ScriptError('timeout', `Volání ${request.path} nedoběhlo v limitu.`, {
return new ScriptError('timeout', `Volání ${request.url} nedoběhlo v limitu.`, {
request,
detail,
cause: err,
@@ -144,7 +244,7 @@ Příčina: ${err.cause.message}` : '';
cause: err,
});
}
return new ScriptError('retryable', `Volání ${request.path} selhalo: ${message}`, {
return new ScriptError('retryable', `Volání ${request.url} selhalo: ${message}`, {
request,
detail,
cause: err,
@@ -171,8 +271,13 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
httpOptions?: ScriptHttpOptions,
): Promise<ScriptHttpResponse<T>> {
const url = buildUrl(target, path, httpOptions);
// Do chyby jde jen cesta, ne cela adresa - v query muze byt tajemstvi.
const request: ScriptRequestInfo = { method, path: url.pathname };
// Server ano, query ne. Bez serveru neni z hlasky poznat, kam to vlastne
// slo, s query by se do hlasky dostalo tajemstvi.
const request: ScriptRequestInfo = {
method,
path: url.pathname,
url: `${url.origin}${url.pathname}`,
};
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
const startedAt = Date.now();
onCall();
@@ -220,7 +325,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
try {
parsed = JSON.parse(raw);
} catch {
throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, {
throw new ScriptError('terminal', `Odpověď ${request.url} není platný JSON.`, {
status: response.status,
request,
detail: redact(truncate(raw, config.errorDetailBytes)),
@@ -228,7 +333,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
}
}
log(`${method} ${url.pathname} -> ${response.status} (${Date.now() - startedAt} ms)`);
log(`${method} ${request.url} -> ${response.status} (${Date.now() - startedAt} ms)`);
const allowed = httpOptions?.allowStatus ?? [];
if (!response.ok && !allowed.includes(response.status)) {
+10 -1
View File
@@ -161,8 +161,17 @@ export function isRetryableKind(kind: ScriptErrorKind): boolean {
/** Ktere volani spadlo. Bez toho je chybova zprava jen pulka informace. */
export interface ScriptRequestInfo {
method: string;
/** Cesta bez domeny. Cela adresa muze nest tajemstvi v query. */
/** Cesta bez domeny. Kvuli zpetne kompatibilite, hlasky pouzivaji `url`. */
path: string;
/**
* Cela adresa **vcetne serveru**, ale bez query - v query muze byt tajemstvi.
*
* Bez serveru se hlaska neda pouzit: "/apps/idoklad/account/agenda vratilo
* 403" nerekne, jestli se to vubec trefilo na spravny stroj, nebo to zaridla
* cizi proxy cestou. A protoze zaklad adresy je z konfigurace a konektor ho
* smi prepsat, nesmi se hadat podle toho, kde je nasazeny portal.
*/
url: string;
}
export class ScriptError extends Error {