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
+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 {