{"version":3,"file":"chaos.cjs","names":[],"sources":["../src/chaos.ts"],"sourcesContent":["/**\n * Chaos testing support for LLMock.\n *\n * Provides probabilistic failure injection — requests can be dropped (500),\n * returned with malformed JSON, or have the connection forcibly disconnected.\n *\n * Precedence: per-request headers > fixture-level config > server-level defaults.\n */\n\nimport type * as http from \"node:http\";\nimport type { ChaosAction, ChaosConfig, ChatCompletionRequest, Fixture } from \"./types.js\";\nimport { writeErrorResponse } from \"./sse-writer.js\";\nimport type { Journal } from \"./journal.js\";\nimport type { Logger } from \"./logger.js\";\nimport type { MetricsRegistry } from \"./metrics.js\";\n\n/**\n * Resolve chaos config from headers, fixture, and server defaults.\n * Header values override fixture values, which override server defaults.\n */\nfunction resolveChaosConfig(\n  fixture: Fixture | null,\n  serverDefaults?: ChaosConfig,\n  rawHeaders?: http.IncomingHttpHeaders,\n  logger?: Logger,\n): ChaosConfig {\n  const base: ChaosConfig = { ...serverDefaults };\n\n  // Fixture-level overrides server defaults\n  if (fixture?.chaos) {\n    if (fixture.chaos.dropRate !== undefined) base.dropRate = fixture.chaos.dropRate;\n    if (fixture.chaos.malformedRate !== undefined) base.malformedRate = fixture.chaos.malformedRate;\n    if (fixture.chaos.disconnectRate !== undefined)\n      base.disconnectRate = fixture.chaos.disconnectRate;\n  }\n\n  // Header overrides everything\n  if (rawHeaders) {\n    const dropHeader = rawHeaders[\"x-aimock-chaos-drop\"];\n    const malformedHeader = rawHeaders[\"x-aimock-chaos-malformed\"];\n    const disconnectHeader = rawHeaders[\"x-aimock-chaos-disconnect\"];\n\n    if (typeof dropHeader === \"string\") {\n      const val = parseFloat(dropHeader);\n      if (isNaN(val)) {\n        logger?.warn(`[chaos] x-aimock-chaos-drop: invalid value \"${dropHeader}\", ignoring`);\n      } else {\n        if (val < 0 || val > 1) {\n          logger?.warn(`[chaos] x-aimock-chaos-drop: value ${val} out of range [0,1], clamping`);\n        }\n        base.dropRate = Math.min(1, Math.max(0, val));\n      }\n    }\n    if (typeof malformedHeader === \"string\") {\n      const val = parseFloat(malformedHeader);\n      if (isNaN(val)) {\n        logger?.warn(\n          `[chaos] x-aimock-chaos-malformed: invalid value \"${malformedHeader}\", ignoring`,\n        );\n      } else {\n        if (val < 0 || val > 1) {\n          logger?.warn(\n            `[chaos] x-aimock-chaos-malformed: value ${val} out of range [0,1], clamping`,\n          );\n        }\n        base.malformedRate = Math.min(1, Math.max(0, val));\n      }\n    }\n    if (typeof disconnectHeader === \"string\") {\n      const val = parseFloat(disconnectHeader);\n      if (isNaN(val)) {\n        logger?.warn(\n          `[chaos] x-aimock-chaos-disconnect: invalid value \"${disconnectHeader}\", ignoring`,\n        );\n      } else {\n        if (val < 0 || val > 1) {\n          logger?.warn(\n            `[chaos] x-aimock-chaos-disconnect: value ${val} out of range [0,1], clamping`,\n          );\n        }\n        base.disconnectRate = Math.min(1, Math.max(0, val));\n      }\n    }\n  }\n\n  // Clamp all resolved rates to [0, 1] regardless of source.\n  // Header values are already clamped above; this covers fixture-level and server defaults.\n  if (base.dropRate !== undefined) base.dropRate = Math.min(1, Math.max(0, base.dropRate));\n  if (base.malformedRate !== undefined)\n    base.malformedRate = Math.min(1, Math.max(0, base.malformedRate));\n  if (base.disconnectRate !== undefined)\n    base.disconnectRate = Math.min(1, Math.max(0, base.disconnectRate));\n\n  return base;\n}\n\n/**\n * Evaluate chaos config and return the triggered action, or null if none.\n * Checks in order: drop, malformed, disconnect — first hit wins.\n */\nexport function evaluateChaos(\n  fixture: Fixture | null,\n  serverDefaults?: ChaosConfig,\n  rawHeaders?: http.IncomingHttpHeaders,\n  logger?: Logger,\n): ChaosAction | null {\n  const config = resolveChaosConfig(fixture, serverDefaults, rawHeaders, logger);\n\n  if (config.dropRate !== undefined && config.dropRate > 0 && Math.random() < config.dropRate) {\n    return \"drop\";\n  }\n  if (\n    config.malformedRate !== undefined &&\n    config.malformedRate > 0 &&\n    Math.random() < config.malformedRate\n  ) {\n    return \"malformed\";\n  }\n  if (\n    config.disconnectRate !== undefined &&\n    config.disconnectRate > 0 &&\n    Math.random() < config.disconnectRate\n  ) {\n    return \"disconnect\";\n  }\n\n  return null;\n}\n\ninterface ChaosJournalContext {\n  method: string;\n  path: string;\n  headers: Record<string, string>;\n  body: ChatCompletionRequest | null;\n}\n\n/**\n * Apply chaos to a request. Returns true if chaos was applied (caller should\n * return early), false if the request should proceed normally.\n *\n * `source` is required so the invariant \"this handler only applies chaos in\n * the <X> phase\" is enforced at the type level. A future handler that grows\n * a proxy path MUST pass `\"proxy\"` explicitly; the default can't drift silently.\n */\nexport function applyChaos(\n  res: http.ServerResponse,\n  fixture: Fixture | null,\n  serverDefaults: ChaosConfig | undefined,\n  rawHeaders: http.IncomingHttpHeaders,\n  journal: Journal,\n  context: ChaosJournalContext,\n  source: \"fixture\" | \"proxy\" | \"internal\",\n  registry?: MetricsRegistry,\n  logger?: Logger,\n): boolean {\n  const action = evaluateChaos(fixture, serverDefaults, rawHeaders, logger);\n  if (!action) return false;\n  applyChaosAction(action, res, fixture, journal, context, source, registry);\n  return true;\n}\n\n/**\n * Apply a specific (already-rolled) chaos action. Exposed so callers that roll\n * the dice themselves can dispatch without re-rolling — important when the\n * caller wants to branch on the action before committing (e.g. pre-flight vs.\n * post-response phases).\n *\n * `source` is required (not optional) so callers can't silently omit it on\n * one branch and journal an ambiguous entry. Pass `\"fixture\"` when a fixture\n * matched (or would have) and `\"proxy\"` when the request was headed for the\n * proxy path.\n */\nexport function applyChaosAction(\n  action: ChaosAction,\n  res: http.ServerResponse,\n  fixture: Fixture | null,\n  journal: Journal,\n  context: ChaosJournalContext,\n  source: \"fixture\" | \"proxy\" | \"internal\",\n  registry?: MetricsRegistry,\n): void {\n  if (registry) {\n    registry.incrementCounter(\"aimock_chaos_triggered_total\", { action, source });\n  }\n\n  switch (action) {\n    case \"drop\": {\n      journal.add({\n        ...context,\n        response: { status: 500, fixture, chaosAction: \"drop\", source },\n      });\n      writeErrorResponse(\n        res,\n        500,\n        JSON.stringify({\n          error: {\n            message: \"Chaos: request dropped\",\n            type: \"server_error\",\n            code: \"chaos_drop\",\n          },\n        }),\n      );\n      return;\n    }\n    case \"malformed\": {\n      journal.add({\n        ...context,\n        response: { status: 200, fixture, chaosAction: \"malformed\", source },\n      });\n      res.writeHead(200, { \"Content-Type\": \"application/json\" });\n      res.end(\"{malformed json: <<<chaos>>>\");\n      return;\n    }\n    case \"disconnect\": {\n      journal.add({\n        ...context,\n        response: { status: 0, fixture, chaosAction: \"disconnect\", source },\n      });\n      res.destroy();\n      return;\n    }\n    default: {\n      const _exhaustive: never = action;\n      void _exhaustive;\n      return;\n    }\n  }\n}\n"],"mappings":"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"}