failure-lambda
Version:
Failure injection for AWS Lambda - chaos engineering made simple
748 lines (733 loc) • 24.5 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/middy.ts
var middy_exports = {};
__export(middy_exports, {
failureLambdaMiddleware: () => failureLambdaMiddleware
});
module.exports = __toCommonJS(middy_exports);
// src/config.ts
var import_client_ssm = require("@aws-sdk/client-ssm");
// src/types.ts
var FAILURE_MODE_ORDER = [
"latency",
"timeout",
"diskspace",
"denylist",
"statuscode",
"exception",
"corruption"
];
var DEFAULT_FLAGS_CONFIG = {};
// src/log.ts
var SOURCE = "failure-lambda";
function log(data) {
console.log(JSON.stringify({ source: SOURCE, level: "info", ...data }));
}
function warn(data) {
console.warn(JSON.stringify({ source: SOURCE, level: "warn", ...data }));
}
function error(data) {
console.error(JSON.stringify({ source: SOURCE, level: "error", ...data }));
}
// src/config.ts
var KNOWN_FLAGS = new Set(FAILURE_MODE_ORDER);
var MAX_DISK_SPACE_MB = 10240;
var MAX_REGEX_LENGTH = 512;
function isUnsafeRegex(pattern) {
if (pattern.length > MAX_REGEX_LENGTH) return true;
let depth = 0;
const hasQuantifierInGroup = [false];
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === "\\") {
i++;
continue;
}
if (ch === "[") {
i++;
while (i < pattern.length) {
if (pattern[i] === "\\") i++;
else if (pattern[i] === "]") break;
i++;
}
continue;
}
if (ch === "(") {
depth++;
hasQuantifierInGroup[depth] = false;
continue;
}
if (ch === ")") {
const groupHadQuantifier = hasQuantifierInGroup[depth] ?? false;
depth = Math.max(0, depth - 1);
if (groupHadQuantifier && i + 1 < pattern.length) {
const next = pattern[i + 1];
if (next === "+" || next === "*" || next === "{") {
return true;
}
}
if (depth > 0 && groupHadQuantifier) {
hasQuantifierInGroup[depth] = true;
}
continue;
}
if (depth > 0 && (ch === "+" || ch === "*")) {
hasQuantifierInGroup[depth] = true;
}
if (depth > 0 && ch === "{") {
const rest = pattern.slice(i);
if (/^\{\d+,/.test(rest)) {
hasQuantifierInGroup[depth] = true;
}
}
}
return false;
}
var DEFAULT_CACHE_TTL_SECONDS = 60;
var configCache = null;
var ssmClient = null;
var hasLoggedSource = false;
function getSSMClient() {
if (ssmClient === null) {
ssmClient = new import_client_ssm.SSMClient({});
}
return ssmClient;
}
function isAppConfigSource() {
return Boolean(process.env.FAILURE_APPCONFIG_CONFIGURATION);
}
function getCacheTtlMs() {
const envValue = process.env.FAILURE_CACHE_TTL;
if (envValue === void 0 || envValue === "") {
if (isAppConfigSource()) {
return 0;
}
return DEFAULT_CACHE_TTL_SECONDS * 1e3;
}
const parsed = Number(envValue);
if (Number.isNaN(parsed) || parsed < 0) {
warn({ action: "config", message: `invalid FAILURE_CACHE_TTL="${envValue}", using default ${DEFAULT_CACHE_TTL_SECONDS}s` });
return DEFAULT_CACHE_TTL_SECONDS * 1e3;
}
if (parsed > 0 && isAppConfigSource()) {
warn({
action: "config",
message: `FAILURE_CACHE_TTL=${parsed}s with AppConfig \u2014 the AppConfig extension already caches at its poll interval; library caching adds staleness`
});
}
return parsed * 1e3;
}
function isCacheValid() {
if (configCache === null) {
return false;
}
const ttlMs = getCacheTtlMs();
if (ttlMs === 0) {
return false;
}
return Date.now() - configCache.fetchedAt < ttlMs;
}
function validateFlagValue(mode, raw) {
const errors = [];
if (typeof raw.enabled !== "boolean") {
errors.push({
field: `${mode}.enabled`,
message: "must be a boolean",
value: raw.enabled
});
}
if (raw.percentage !== void 0) {
if (typeof raw.percentage !== "number" || !Number.isInteger(raw.percentage) || raw.percentage < 0 || raw.percentage > 100) {
errors.push({
field: `${mode}.percentage`,
message: "must be an integer between 0 and 100",
value: raw.percentage
});
}
}
if (mode === "latency") {
if (raw.min_latency !== void 0) {
if (typeof raw.min_latency !== "number" || raw.min_latency < 0) {
errors.push({
field: `${mode}.min_latency`,
message: "must be a non-negative number",
value: raw.min_latency
});
}
}
if (raw.max_latency !== void 0) {
if (typeof raw.max_latency !== "number" || raw.max_latency < 0) {
errors.push({
field: `${mode}.max_latency`,
message: "must be a non-negative number",
value: raw.max_latency
});
}
}
if (typeof raw.min_latency === "number" && typeof raw.max_latency === "number" && raw.min_latency > raw.max_latency) {
errors.push({
field: `${mode}.max_latency`,
message: "max_latency must be >= min_latency",
value: raw.max_latency
});
}
}
if (mode === "exception") {
if (raw.exception_msg !== void 0 && typeof raw.exception_msg !== "string") {
errors.push({
field: `${mode}.exception_msg`,
message: "must be a string",
value: raw.exception_msg
});
}
}
if (mode === "statuscode") {
if (raw.status_code !== void 0) {
if (typeof raw.status_code !== "number" || raw.status_code < 100 || raw.status_code > 599) {
errors.push({
field: `${mode}.status_code`,
message: "must be an HTTP status code (100-599)",
value: raw.status_code
});
}
}
}
if (mode === "diskspace") {
if (raw.disk_space !== void 0) {
if (typeof raw.disk_space !== "number" || raw.disk_space <= 0 || raw.disk_space > MAX_DISK_SPACE_MB) {
errors.push({
field: `${mode}.disk_space`,
message: `must be between 1 and ${MAX_DISK_SPACE_MB} (MB)`,
value: raw.disk_space
});
}
}
}
if (mode === "denylist") {
if (raw.deny_list !== void 0) {
if (!Array.isArray(raw.deny_list) || !raw.deny_list.every((item) => typeof item === "string")) {
errors.push({
field: `${mode}.deny_list`,
message: "must be an array of strings",
value: raw.deny_list
});
} else {
for (let i = 0; i < raw.deny_list.length; i++) {
const pattern = raw.deny_list[i];
try {
new RegExp(pattern);
} catch {
errors.push({
field: `${mode}.deny_list[${i}]`,
message: "invalid regular expression",
value: pattern
});
continue;
}
if (isUnsafeRegex(pattern)) {
errors.push({
field: `${mode}.deny_list[${i}]`,
message: "potentially unsafe pattern (nested quantifiers may cause excessive backtracking)",
value: pattern
});
}
}
}
}
}
if (mode === "timeout") {
if (raw.timeout_buffer_ms !== void 0) {
if (typeof raw.timeout_buffer_ms !== "number" || raw.timeout_buffer_ms < 0) {
errors.push({
field: `${mode}.timeout_buffer_ms`,
message: "must be a non-negative number",
value: raw.timeout_buffer_ms
});
}
}
}
if (mode === "corruption") {
if (raw.body !== void 0 && typeof raw.body !== "string") {
errors.push({
field: `${mode}.body`,
message: "must be a string",
value: raw.body
});
}
}
if (raw.match !== void 0) {
if (!Array.isArray(raw.match)) {
errors.push({
field: `${mode}.match`,
message: "must be an array of match condition objects",
value: raw.match
});
} else {
const VALID_OPERATORS = /* @__PURE__ */ new Set(["eq", "exists", "startsWith", "regex"]);
for (let i = 0; i < raw.match.length; i++) {
const condition = raw.match[i];
if (typeof condition !== "object" || condition === null) {
errors.push({
field: `${mode}.match[${i}]`,
message: "must be an object with a string path field",
value: condition
});
continue;
}
const cond = condition;
if (typeof cond.path !== "string") {
errors.push({
field: `${mode}.match[${i}].path`,
message: "must be a string",
value: cond.path
});
}
const operator = cond.operator ?? "eq";
if (cond.operator !== void 0 && !VALID_OPERATORS.has(operator)) {
errors.push({
field: `${mode}.match[${i}].operator`,
message: `must be one of: eq, exists, startsWith, regex`,
value: cond.operator
});
}
if (operator !== "exists" && typeof cond.value !== "string") {
errors.push({
field: `${mode}.match[${i}].value`,
message: "must be a string (required for all operators except 'exists')",
value: cond.value
});
}
if (operator === "regex" && typeof cond.value === "string") {
try {
new RegExp(cond.value);
} catch {
errors.push({
field: `${mode}.match[${i}].value`,
message: "invalid regular expression",
value: cond.value
});
continue;
}
if (isUnsafeRegex(cond.value)) {
errors.push({
field: `${mode}.match[${i}].value`,
message: "potentially unsafe pattern (nested quantifiers may cause excessive backtracking)",
value: cond.value
});
}
}
}
}
}
return errors;
}
function parseFlags(raw) {
if ("isEnabled" in raw || "failureMode" in raw) {
warn({
action: "config",
message: "detected 0.x configuration format \u2014 this version requires the v1.0 feature-flag format. See https://github.com/gunnargrosch/failure-lambda#migration-from-0x"
});
}
const config = {};
for (const key of Object.keys(raw)) {
if (!KNOWN_FLAGS.has(key)) {
continue;
}
const mode = key;
const flagRaw = raw[mode];
if (typeof flagRaw !== "object" || flagRaw === null || Array.isArray(flagRaw)) {
warn({ action: "config", mode, message: "must be an object, skipping" });
continue;
}
const flagObj = flagRaw;
const validationErrors = validateFlagValue(mode, flagObj);
if (validationErrors.length > 0) {
for (const validationError of validationErrors) {
warn({ action: "config", field: validationError.field, message: validationError.message, value: validationError.value });
}
warn({ action: "config", mode, message: "skipping flag due to validation errors" });
continue;
}
config[mode] = flagObj;
}
return config;
}
function resolveFailures(config) {
const failures = [];
for (const mode of FAILURE_MODE_ORDER) {
const flag = config[mode];
if (flag === void 0 || !flag.enabled) {
continue;
}
failures.push({
mode,
percentage: Math.max(0, Math.min(100, flag.percentage ?? 100)),
flag
});
}
return failures;
}
async function fetchFromAppConfig() {
const appConfigPort = process.env.AWS_APPCONFIG_EXTENSION_HTTP_PORT ?? "2772";
const application = process.env.FAILURE_APPCONFIG_APPLICATION;
const environment = process.env.FAILURE_APPCONFIG_ENVIRONMENT;
const configuration = process.env.FAILURE_APPCONFIG_CONFIGURATION;
const url = `http://localhost:${appConfigPort}/applications/${application}/environments/${environment}/configurations/${configuration}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`AppConfig fetch failed: ${response.status} ${response.statusText}`
);
}
const json = await response.json();
return parseFlags(json);
}
async function fetchFromSSM() {
const parameterName = process.env.FAILURE_INJECTION_PARAM;
const client = getSSMClient();
const command = new import_client_ssm.GetParameterCommand({ Name: parameterName });
const response = await client.send(command);
const rawValue = response.Parameter?.Value;
if (rawValue === void 0) {
throw new Error(`SSM parameter "${parameterName}" has no value`);
}
const json = JSON.parse(rawValue);
return parseFlags(json);
}
async function getConfig() {
if (isCacheValid() && configCache !== null) {
return configCache.config;
}
try {
let config;
let source;
if (process.env.FAILURE_APPCONFIG_CONFIGURATION) {
config = await fetchFromAppConfig();
source = "appconfig";
} else if (process.env.FAILURE_INJECTION_PARAM) {
config = await fetchFromSSM();
source = "ssm";
} else {
return { ...DEFAULT_FLAGS_CONFIG };
}
if (!hasLoggedSource) {
const cacheTtlMs = getCacheTtlMs();
log({
action: "config",
config_source: source,
cache_ttl_seconds: cacheTtlMs / 1e3,
enabled_flags: Object.keys(config).filter(
(k) => config[k]?.enabled
)
});
hasLoggedSource = true;
}
configCache = {
config,
fetchedAt: Date.now()
};
return config;
} catch (err) {
error({ action: "config", message: "error fetching config", error: String(err) });
return { ...DEFAULT_FLAGS_CONFIG };
}
}
// src/failures/latency.ts
async function injectLatency(flag) {
const minLatency = flag.min_latency ?? 0;
const maxLatency = flag.max_latency ?? 0;
const latencyRange = Math.max(0, maxLatency - minLatency);
const injectedLatency = Math.floor(minLatency + Math.random() * latencyRange);
log({ mode: "latency", action: "inject", latency_ms: injectedLatency, min_latency: minLatency, max_latency: maxLatency });
await new Promise((resolve) => setTimeout(resolve, injectedLatency));
}
// src/failures/exception.ts
function injectException(flag) {
const message = flag.exception_msg ?? "Injected exception";
log({ mode: "exception", action: "inject", exception_msg: message });
throw new Error(message);
}
// src/failures/statuscode.ts
function injectStatusCode(flag) {
const statusCode = flag.status_code ?? 500;
log({ mode: "statuscode", action: "inject", status_code: statusCode });
return {
statusCode,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: `Injected status code ${statusCode}` })
};
}
// src/failures/diskspace.ts
var import_node_child_process = require("child_process");
var import_node_fs = require("fs");
var DISKSPACE_PREFIX = "diskspace-failure-";
function injectDiskSpace(flag) {
const diskSpaceMB = flag.disk_space ?? 100;
log({ mode: "diskspace", action: "inject", disk_space_mb: diskSpaceMB });
const result = (0, import_node_child_process.spawnSync)("dd", [
"if=/dev/zero",
`of=/tmp/${DISKSPACE_PREFIX}${Date.now()}.tmp`,
"count=1024",
`bs=${diskSpaceMB * 1024}`
]);
if (result.error) {
error({ mode: "diskspace", action: "error", message: result.error.message });
} else if (result.status !== 0) {
const stderr = result.stderr?.toString().trim();
error({ mode: "diskspace", action: "error", message: `dd exited with status ${result.status}`, stderr });
}
}
function clearDiskSpace() {
try {
const files = (0, import_node_fs.readdirSync)("/tmp").filter((f) => f.startsWith(DISKSPACE_PREFIX));
for (const file of files) {
(0, import_node_fs.unlinkSync)(`/tmp/${file}`);
}
if (files.length > 0) {
log({ mode: "diskspace", action: "clear", files_removed: files.length });
}
} catch (e) {
warn({ mode: "diskspace", action: "clear_error", message: e.message });
}
}
// src/failures/denylist.ts
var import_node_dns = __toESM(require("dns"), 1);
var originalLookup = import_node_dns.default.lookup;
var isActive = false;
var activePatterns = [];
function clearDenylist() {
if (isActive) {
import_node_dns.default.lookup = originalLookup;
isActive = false;
activePatterns = [];
}
}
function injectDenylist(flag) {
const denylistPatterns = flag.deny_list ?? [];
log({ mode: "denylist", action: "inject", patterns: denylistPatterns });
activePatterns = [];
for (const pattern of denylistPatterns) {
try {
activePatterns.push(new RegExp(pattern));
} catch (e) {
warn({ mode: "denylist", action: "error", message: `invalid regex "${pattern}"`, error: e.message });
}
}
if (!isActive) {
import_node_dns.default.lookup = function blockedLookup(hostname, ...args) {
const callback = args[args.length - 1];
const rest = args.slice(0, -1);
if (activePatterns.some((regex) => regex.test(hostname))) {
log({ mode: "denylist", action: "block", hostname });
const err = new Error(
`getaddrinfo ENOTFOUND ${hostname}`
);
err.code = "ENOTFOUND";
err.hostname = hostname;
err.syscall = "getaddrinfo";
process.nextTick(() => callback(err));
return;
}
originalLookup.call(import_node_dns.default, hostname, ...rest, callback);
};
isActive = true;
}
}
// src/failures/timeout.ts
async function injectTimeout(flag, context) {
const bufferMs = flag.timeout_buffer_ms ?? 0;
const remaining = context.getRemainingTimeInMillis();
const sleepMs = Math.max(0, remaining - bufferMs);
log({ mode: "timeout", action: "inject", sleep_ms: sleepMs, buffer_ms: bufferMs, remaining_ms: remaining });
await new Promise((resolve) => setTimeout(resolve, sleepMs));
}
// src/failures/corruption.ts
function corruptResponse(flag, result) {
if (flag.body !== void 0) {
log({ mode: "corruption", action: "inject", method: "replace" });
if (typeof result === "object" && result !== null && "body" in result) {
return { ...result, body: flag.body };
}
warn({ mode: "corruption", message: "response has no body field; wrapping in { body }" });
return { body: flag.body };
}
log({ mode: "corruption", action: "inject", method: "mangle" });
if (typeof result === "object" && result !== null && "body" in result) {
const obj = result;
if (typeof obj.body === "string") {
return { ...obj, body: mangleString(obj.body) };
}
}
warn({ mode: "corruption", message: "response has no string body field to mangle; returning unchanged" });
return result;
}
function mangleString(input) {
if (input.length === 0) return input;
const truncatePoint = Math.floor(input.length * (0.3 + Math.random() * 0.5));
return input.slice(0, truncatePoint) + "\uFFFD".repeat(3);
}
// src/matching.ts
var regexCache = /* @__PURE__ */ new Map();
function getCachedRegex(pattern) {
let cached = regexCache.get(pattern);
if (cached === void 0) {
cached = new RegExp(pattern);
regexCache.set(pattern, cached);
}
return cached;
}
function getNestedValue(obj, path) {
let current = obj;
for (const part of path.split(".")) {
if (current === null || current === void 0 || typeof current !== "object") return void 0;
current = current[part];
}
return current;
}
function matchOperator(actual, operator, value) {
switch (operator) {
case "exists":
return actual !== null && actual !== void 0;
case "startsWith":
if (actual === null || actual === void 0) return false;
return String(actual).startsWith(value ?? "");
case "regex":
if (actual === null || actual === void 0) return false;
return getCachedRegex(value ?? "").test(String(actual));
case "eq":
default:
if (actual === null || actual === void 0) return false;
return String(actual) === value;
}
}
function matchesConditions(event, conditions) {
return conditions.every((condition) => {
const actual = getNestedValue(event, condition.path);
const operator = condition.operator ?? "eq";
return matchOperator(actual, operator, condition.value);
});
}
// src/orchestration.ts
async function runPreHandlerInjections(failures, event, context, dryRun = false) {
if (!dryRun) {
clearDenylist();
clearDiskSpace();
}
for (const failure of failures) {
if (failure.mode === "corruption") continue;
if (failure.flag.match && !matchesConditions(event, failure.flag.match)) continue;
const roll = Math.random() * 100;
if (roll >= failure.percentage) continue;
if (dryRun) {
log({ mode: failure.mode, action: "dryrun", percentage: failure.percentage, roll });
continue;
}
switch (failure.mode) {
case "latency":
await injectLatency(failure.flag);
break;
case "timeout":
await injectTimeout(failure.flag, context);
break;
case "diskspace":
injectDiskSpace(failure.flag);
break;
case "denylist":
injectDenylist(failure.flag);
break;
case "statuscode":
return { shortCircuit: injectStatusCode(failure.flag) };
case "exception":
injectException(failure.flag);
}
}
return void 0;
}
function runPostHandlerInjections(failures, event, result, dryRun = false) {
let current = result;
for (const failure of failures) {
if (failure.mode !== "corruption") continue;
if (failure.flag.match && !matchesConditions(event, failure.flag.match)) continue;
const roll = Math.random() * 100;
if (roll >= failure.percentage) continue;
if (dryRun) {
log({ mode: failure.mode, action: "dryrun", percentage: failure.percentage, roll });
continue;
}
current = corruptResponse(failure.flag, current);
}
return current;
}
// src/middy.ts
function failureLambdaMiddleware(options) {
return {
before: async (request) => {
if (process.env.FAILURE_LAMBDA_DISABLED === "true") {
return;
}
const configProvider = options?.configProvider ?? getConfig;
const flagsConfig = await configProvider();
const failures = resolveFailures(flagsConfig);
request.internal = { ...request.internal, failureLambdaFailures: failures };
const dryRun = options?.dryRun === true;
const preResult = await runPreHandlerInjections(
failures,
request.event,
request.context,
dryRun
);
if (preResult) {
request.response = preResult.shortCircuit;
return preResult.shortCircuit;
}
},
after: async (request) => {
const failures = request.internal?.failureLambdaFailures ?? [];
const dryRun = options?.dryRun === true;
request.response = runPostHandlerInjections(
failures,
request.event,
request.response,
dryRun
);
},
onError: async (request) => {
error({ action: "error", message: request.error?.message ?? "unknown error" });
clearDenylist();
clearDiskSpace();
}
};
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
failureLambdaMiddleware
});
if (module.exports.default) { Object.assign(module.exports.default, module.exports); module.exports = module.exports.default; }
//# sourceMappingURL=middy.cjs.map