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:
co-authored by
Claude Opus 5
parent
6297bbf480
commit
5a124a53d8
+118
-13
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user