Cele chybove hlaseni u konektoru i skriptu

U odpovedi 401 nebo 400 je duvod napsany v tele odpovedi sluzby, ne v tom, ze
prislo 401. Dosud se telo zkracovalo na 400 znaku a u overeni konektoru se
zahazovalo cele - zbyla veta "Pristup zamitnut", podle ktere se neda hledat.

- ScriptError nese `request` (metoda a cesta) a `detail` s celou odpovedi
  sluzby, zkracenou az na SCRIPT_ERROR_DETAIL_BYTES (vychozi 8 kB). Chyby jsou
  vzacne, takze objem neroste jako u logu uspesnych kroku
- do detailu jde surove telo, ne prochazene pres JSON.stringify. U chyby chceme
  presne to, co sluzba poslala, vcetne HTML nebo prosteho textu
- u chyby spojeni se pridava i `cause`, u neocekavane vyjimky zasobnik volani
  (mimo produkci, stejne jako u centralniho error handleru)
- overeni konektoru vraci `detail`, `status` i `request`
- cely detail jde i do logu serveru, at je to dohledatelne bez portalu
- do chyby se dava jen cesta, ne cela adresa: v query muze byt tajemstvi
- nova komponenta ErrorDetail: rozbaleni cele odpovedi a tlacitko Kopirovat vse

Overeno: npm run typecheck prochazi na serveru i webu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 14:38:43 +02:00
co-authored by Claude Opus 5
parent ad56c7f513
commit 3279dd7dac
9 changed files with 306 additions and 70 deletions
+8
View File
@@ -94,6 +94,14 @@ export const config = {
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
/**
* Strop na text chybove odpovedi cizi sluzby.
*
* Zamerne velky. Odpoved na 401 nebo 400 obsahuje duvod a bez nej se chyba
* nedá dohledat. Chyby jsou navic vzacne, takze objem neroste jako u logu
* uspesnych kroku.
*/
errorDetailBytes: positiveNumber(process.env.SCRIPT_ERROR_DETAIL_BYTES, 8_000),
/**
* Strop na jednu strukturu (parametr typu object nebo list).
* Radek s vystupem kroku je nejrychleji rostouci tabulka v systemu, takze
+24 -5
View File
@@ -37,10 +37,11 @@ import {
visibleServices,
type Service,
} from '../data/services.js';
import { config } from '../config.js';
import { resolveTarget, serviceBaseUrl } from '../scripts/connections.js';
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
import { createRedactor, describe } from '../scripts/util.js';
import { createRedactor, describe, truncate } from '../scripts/util.js';
export const connectorsRouter = Router();
@@ -298,18 +299,36 @@ connectorsRouter.post('/:id/test', async (req, res) => {
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í.',
});
} catch (err) {
const message =
err instanceof ScriptError ? redact(err.message) : redact(describe(err, 200));
// 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 detail = isScriptError
? err.detail
? redact(err.detail)
: undefined
: redact(truncate(String(err instanceof Error ? err.stack ?? err.message : err), config.errorDetailBytes));
setConnectorStatus(connector.id, 'error', message, [tenantId]);
console.warn(`[connectors] test ${connector.id} selhal: ${message}`);
console.warn(
`[connectors] test ${connector.id} selhal: ${message}` + (detail ? `
${detail}` : ''),
);
// Neuspesne overeni neni chyba API, je to vysledek. Proto 200.
return res.json({ ok: false, checked, message });
return res.json({
ok: false,
checked,
message,
...(isScriptError && err.status !== undefined ? { status: err.status } : {}),
request: isScriptError && err.request ? err.request : { method: 'GET', path },
...(detail ? { detail } : {}),
});
} finally {
clearTimeout(timer);
}
+60 -21
View File
@@ -10,8 +10,14 @@
import { config } from '../config.js';
import type { ResolvedTarget } from './connections.js';
import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
import { describe } from './util.js';
import {
ScriptError,
type ScriptHttp,
type ScriptHttpOptions,
type ScriptHttpResponse,
type ScriptRequestInfo,
} from './types.js';
import { truncate } from './util.js';
/** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
@@ -80,41 +86,69 @@ function buildUrl(target: ResolvedTarget, path: string, options?: ScriptHttpOpti
return url;
}
function statusError(status: number, url: URL, detail: string): ScriptError {
const where = `${url.pathname} vrátilo HTTP ${status}`;
/**
* 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.
*/
function statusError(
status: number,
request: ScriptRequestInfo,
detail: string,
): ScriptError {
const where = `${request.method} ${request.path} vrátilo HTTP ${status}`;
const options = { status, detail, request };
if (retryableStatuses.has(status)) {
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, {
status,
detail,
});
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, options);
}
if (status === 401 || status === 403) {
return new ScriptError('config', `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, {
status,
detail,
});
return new ScriptError(
'config',
`Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`,
options,
);
}
if (status === 404) {
return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, { status, detail });
return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, options);
}
return new ScriptError('terminal', `Volání selhalo: ${where}.`, { status, detail });
return new ScriptError('terminal', `Volání selhalo: ${where}.`, options);
}
function transportError(err: unknown, url: URL): ScriptError {
function transportError(err: unknown, request: ScriptRequestInfo): ScriptError {
if (err instanceof ScriptError) return err;
const code =
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
const name = err instanceof Error ? err.name : '';
const message = err instanceof Error ? err.message : String(err);
// Puvodni chyba vcetne pricin. `cause` nese u fetch to podstatne.
const cause =
err instanceof Error && err.cause instanceof Error ? `
Příčina: ${err.cause.message}` : '';
const detail = truncate(`${name}: ${message}${cause}`, config.errorDetailBytes);
if (name === 'AbortError' || name === 'TimeoutError') {
return new ScriptError('timeout', `Volání ${url.pathname} nedoběhlo v limitu.`, { cause: err });
return new ScriptError('timeout', `Volání ${request.path} nedoběhlo v limitu.`, {
request,
detail,
cause: err,
});
}
if (retryableCodes.has(code)) {
return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { cause: err });
return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, {
request,
detail,
cause: err,
});
}
return new ScriptError('retryable', `Volání ${url.pathname} selhalo: ${message}`, { cause: err });
return new ScriptError('retryable', `Volání ${request.path} selhalo: ${message}`, {
request,
detail,
cause: err,
});
}
export interface CreateHttpOptions {
@@ -137,6 +171,8 @@ 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 };
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
const startedAt = Date.now();
onCall();
@@ -157,7 +193,7 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
body: hasBody ? JSON.stringify(body) : undefined,
});
} catch (err) {
throw transportError(err, url);
throw transportError(err, request);
}
const declaredSize = Number(response.headers.get('content-length') ?? 0);
@@ -186,7 +222,8 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
} catch {
throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, {
status: response.status,
detail: redact(describe(raw, 300)),
request,
detail: redact(truncate(raw, config.errorDetailBytes)),
});
}
}
@@ -195,7 +232,9 @@ export function createHttp(options: CreateHttpOptions): ScriptHttp {
const allowed = httpOptions?.allowStatus ?? [];
if (!response.ok && !allowed.includes(response.status)) {
throw statusError(response.status, url, redact(describe(parsed, 400)));
// Zamerne surove telo, ne prochazene pres JSON.stringify - u chyby chceme
// presne to, co sluzba poslala, vcetne pripadneho HTML nebo textu.
throw statusError(response.status, request, redact(truncate(raw, config.errorDetailBytes)));
}
return { status: response.status, body: parsed as T };
+34 -15
View File
@@ -21,11 +21,11 @@ import {
isRetryableKind,
ScriptError,
type ScriptContext,
type ScriptErrorKind,
type ScriptLogEntry,
type ScriptRunError,
type ScriptRunResult,
} from './types.js';
import { createRedactor, describe, scriptUtil } from './util.js';
import { createRedactor, describe, scriptUtil, truncate } from './util.js';
import { validateValues } from './values.js';
export interface RunScriptOptions {
@@ -51,22 +51,36 @@ function defaultIdempotencyKey(scriptId: string, inputs: unknown): string {
return `${scriptId}:${hash.slice(0, 24)}`;
}
function toRunError(
err: unknown,
redact: (value: string) => string,
): { kind: ScriptErrorKind; message: string; status?: number; detail?: string } {
/**
* Prevede vyjimku na popis chyby pro klienta.
*
* Vraci **cely** detail, tedy telo odpovedi sluzby, a u neocekavane vyjimky
* i zasobnik volani. Bez toho uzivatel vidi jen "volani selhalo" a nema podle
* ceho hledat. Zasobnik jen mimo produkci, stejne jako u error handleru.
*/
function toRunError(err: unknown, redact: (value: string) => string): ScriptRunError {
if (err instanceof ScriptError) {
return {
kind: err.kind,
message: redact(err.message),
retryable: isRetryableKind(err.kind),
...(err.status !== undefined ? { status: err.status } : {}),
...(err.detail !== undefined ? { detail: redact(err.detail) } : {}),
...(err.request !== undefined ? { request: err.request } : {}),
};
}
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
// Neocekavana vyjimka ve skriptu. Opakovat ji nema smysl, kod se sam nespravi.
return { kind: 'internal', message: redact(`Skript selhal: ${message}`) };
return {
kind: 'internal',
message: redact(`Skript selhal: ${message}`),
retryable: false,
detail: redact(truncate(String(stack ?? message), config.errorDetailBytes)),
...(config.isProduction || stack === undefined ? {} : { stack: redact(stack) }),
};
}
export async function runScript(
@@ -165,10 +179,14 @@ export async function runScript(
config: Object.freeze({ ...target.serviceConfig }),
idempotencyKey,
fail(message, detail) {
throw new ScriptError('terminal', message, { detail: describe(detail) });
throw new ScriptError('terminal', message, {
detail: describe(detail, config.errorDetailBytes),
});
},
retry(message, detail) {
throw new ScriptError('retryable', message, { detail: describe(detail) });
throw new ScriptError('retryable', message, {
detail: describe(detail, config.errorDetailBytes),
});
},
};
@@ -177,12 +195,13 @@ export async function runScript(
returned = await script.run(validatedInputs.values, ctx);
} catch (err) {
const error = toRunError(err, redact);
console.warn(`[scripts] ${scriptId} selhal (${error.kind}): ${error.message}`);
return finish({
ok: false,
outputs: {},
error: { ...error, retryable: isRetryableKind(error.kind) },
});
// Do logu serveru cely detail, at je to dohledatelne i bez portalu.
console.warn(
`[scripts] ${scriptId} selhal (${error.kind}): ${error.message}` +
(error.detail ? `
${error.detail}` : ''),
);
return finish({ ok: false, outputs: {}, error });
} finally {
clearTimeout(timer);
}
+25 -2
View File
@@ -158,23 +158,41 @@ export function isRetryableKind(kind: ScriptErrorKind): boolean {
return retryableKinds.includes(kind);
}
/** 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. */
path: string;
}
export class ScriptError extends Error {
readonly kind: ScriptErrorKind;
/** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */
readonly status?: number;
/** Kratky detail k zobrazeni. Uz zredigovany, bez tajemstvi. */
/**
* Cela odpoved sluzby. Uz zredigovana, bez tajemstvi.
* Zkracuje se az na `errorDetailBytes`, protoze prave tady je napsane,
* co sluzbe vadilo.
*/
readonly detail?: string;
readonly request?: ScriptRequestInfo;
constructor(
kind: ScriptErrorKind,
message: string,
options: { status?: number; detail?: string; cause?: unknown } = {},
options: {
status?: number;
detail?: string;
request?: ScriptRequestInfo;
cause?: unknown;
} = {},
) {
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
this.name = 'ScriptError';
this.kind = kind;
this.status = options.status;
this.detail = options.detail;
this.request = options.request;
}
}
@@ -295,7 +313,12 @@ export interface ScriptRunError {
message: string;
retryable: boolean;
status?: number;
/** Cela odpoved sluzby, uz zredigovana. Tady je napsane, co ji vadilo. */
detail?: string;
/** Ktere volani spadlo. */
request?: ScriptRequestInfo;
/** Zasobnik volani. Jen mimo produkci, stejne jako u error handleru. */
stack?: string;
/** Vyplnene jen u chyb ve vstupu nebo vystupu. */
issues?: FieldIssue[];
}