fix: restore V3 HTTP observability

Project one typed HttpExecutionObservation per logical V3 execution through a
closed composition-root projector: only registered diagnostic context keys and
bucketed values reach the sinks, and terminal non-abort failures now emit
exactly one api.request.failed telemetry event. Caller cancellation and scope
fencing record a diagnostic but never a failure event.

routeId becomes a required input at the installed operation-executor boundary
so the feature gateway's low-cardinality route identity survives to the sink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 22:42:51 +09:00
co-authored by Claude Opus 5
parent f7bec8274b
commit 67cc5b6d2c
9 changed files with 559 additions and 59 deletions
@@ -37,6 +37,21 @@ vendor 결정 전에는 안전한 기본값이 아니다.
abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다. abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다.
`api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만 `api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만
정확히 한 번 발행한다. 정확히 한 번 발행한다.
5-1. V2 client와 V3 contract executor는 각각 자신의 logical execution에 대해
이 규칙을 만족한다. V3에서는 execution site가 `HttpExecutionObservation`
typed record 하나만 만들고, composition root의
`createHttpObservationProjector`가 유일한 projection authority다. observation은
arbitrary context map이 아니며 projector는 `route_id`, `operation_id`,
`operation`, `outcome`, `error_kind`, `http_status_group`,
`attempt_count_bucket`, `duration_bucket`만 사용한다. raw attempt count,
duration, status, URL, intent, key, input identity와 내부 `terminalReason`
sink로 나가지 않는다. effect certainty가 운영상 필요해지면 `effect_certainty`
key와 닫힌 value policy를 contract·fixture·이 ADR에 동시에 추가한 뒤에만
전달한다.
5-2. caller cancellation과 scope fence는 API failure가 아니다. diagnostics는 한
번 남기고 `api.request.failed`는 발행하지 않는다.
5-3. `routeId`는 installed operation-executor 경계의 필수 입력이다. feature
gateway가 소유한 low-cardinality route identity를 URL에서 재구성하지 않는다.
6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`, 6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`,
`telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage `telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage
실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다. 실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다.
@@ -79,6 +94,11 @@ route/application/HTTP/cache/storage/bootstrap
queue full, sink/observer failure와 pre-mount boot evidence를 검증한다. queue full, sink/observer failure와 pre-mount boot evidence를 검증한다.
- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer - HTTP integration은 success, retry recovery, terminal failure와 abort의 producer
횟수, route/operation/correlation context와 요청 값 비노출을 검증한다. 횟수, route/operation/correlation context와 요청 값 비노출을 검증한다.
- `tests/integration/http-execution-v3-observability.test.ts`는 V3 terminal
outcome이 실제로 closed allowlist를 통과하는지, terminal non-abort failure가
`api.request.failed`를 정확히 한 번 발행하는지, cancellation/scope fence가
발행하지 않는지, feature route ID가 executor 경계까지 보존되는지, sink 예외가
HTTP 결과를 바꾸지 못하는지를 검증한다.
- cache/storage/release/application/runtime test는 각 production wiring과 - cache/storage/release/application/runtime test는 각 production wiring과
diagnostics failure isolation을 검증한다. diagnostics failure isolation을 검증한다.
@@ -72,7 +72,7 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence | | ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | — | `NOT_STARTED` | diagnostics/telemetry producer gate regression | — | | N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | `fix: restore V3 HTTP observability` | `FIXED_NOT_RELEASED` | diagnostics/telemetry producer gate regression | Red 5/5 failed → green 5/5; `check:diagnostics` PASS (8 diagnostics, 5 telemetry producers); `check:types` PASS; `check:architecture` PASS |
| N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | — | `NOT_STARTED` | authenticated request 4xx spike after profile enforcement | — | | N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | — | `NOT_STARTED` | authenticated request 4xx spike after profile enforcement | — |
| N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts` | — | `NOT_STARTED` | command effect verdict regression | — | | N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts` | — | `NOT_STARTED` | command effect verdict regression | — |
| N-04 | Live composition teardown | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts` | — | `NOT_STARTED` | telemetry delivery loss after teardown change | — | | N-04 | Live composition teardown | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts` | — | `NOT_STARTED` | telemetry delivery loss after teardown change | — |
+87 -11
View File
@@ -134,6 +134,12 @@ export type CancellationOwner =
| "DEADLINE"; | "DEADLINE";
export interface HttpExecutionContext { export interface HttpExecutionContext {
/**
* §7.4. The low-cardinality route identity that owns this logical execution.
* It is required at the installed operation-executor boundary so a terminal
* outcome can always be attributed without reconstructing it from a URL.
*/
readonly routeId: string;
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
readonly scope: CacheScopeSnapshot; readonly scope: CacheScopeSnapshot;
readonly intent?: MutationIntent; readonly intent?: MutationIntent;
@@ -147,13 +153,70 @@ export interface ContractHttpExecutor {
): Promise<HttpExecutionOutcome<WireOutput, Problem>>; ): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
} }
/**
* §7.4 / VD-07. One typed internal record per logical execution. It is not an
* arbitrary context map: the composition root owns the projection into the
* closed diagnostics and telemetry buckets, and raw attempt count, duration and
* status never leave that projection.
*/
export type HttpExecutionObservation = Readonly<{ export type HttpExecutionObservation = Readonly<{
routeId: string;
operationId: string;
diagnosticsOperation: string; diagnosticsOperation: string;
outcome: string; outcome: HttpExecutionOutcome<unknown, unknown>["kind"];
attempts: number; errorKind: string;
certainty: string; status?: number;
attemptCount: number;
durationMs: number;
effect: HttpEffectCertainty;
cancellationOwner?: CancellationOwner;
/**
* The internal terminal-reason label recorded by the execution site. It is
* evidence for the HTTP scenario catalog only; the composition-root
* projection never forwards it to diagnostics or telemetry.
*/
terminalReason: string;
}>; }>;
/**
* The outcome is the single authority for the observed error kind. The terminal
* reason only distinguishes an internal runtime failure from a transport
* failure, because both surface as the same public outcome.
*/
function observationErrorKind(
outcome: HttpExecutionOutcome<unknown, unknown>,
terminalReason: string,
): string {
switch (outcome.kind) {
case "SUCCESS":
return "NONE";
case "PROBLEM":
return "PROBLEM";
case "UNAUTHENTICATED":
return "UNAUTHENTICATED";
case "FORBIDDEN":
return "FORBIDDEN";
case "RATE_LIMITED":
return "RATE_LIMITED";
case "CONTRACT_VIOLATION":
return outcome.violation.kind;
case "TRANSPORT_FAILURE":
return terminalReason === "RUNTIME_FAILURE"
? "RUNTIME_FAILURE"
: outcome.failure.kind;
case "CANCELLED":
return "REQUEST_ABORTED";
}
}
function observationStatus(
outcome: HttpExecutionOutcome<unknown, unknown>,
): number | undefined {
return outcome.kind === "SUCCESS" || outcome.kind === "PROBLEM"
? outcome.metadata.status
: undefined;
}
export type ContractHttpExecutorDependencies = Readonly<{ export type ContractHttpExecutorDependencies = Readonly<{
baseUrl: string; baseUrl: string;
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */ /** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
@@ -308,7 +371,8 @@ export function createContractHttpExecutor(
// §8.5. One monotonic deadline covers credential resolution, encoding, // §8.5. One monotonic deadline covers credential resolution, encoding,
// backoff, every physical attempt, body read and validation. // backoff, every physical attempt, body read and validation.
const deadlineAt = now() + policy.totalDeadlineMs; const startedAt = now();
const deadlineAt = startedAt + policy.totalDeadlineMs;
const remaining = () => deadlineAt - now(); const remaining = () => deadlineAt - now();
let attemptState: PhysicalAttemptState = "PREPARING"; let attemptState: PhysicalAttemptState = "PREPARING";
@@ -353,16 +417,28 @@ export function createContractHttpExecutor(
const finish = ( const finish = (
outcome: HttpExecutionOutcome<WireOutput, Problem>, outcome: HttpExecutionOutcome<WireOutput, Problem>,
certainty: string, terminalReason: string,
): HttpExecutionOutcome<WireOutput, Problem> => { ): HttpExecutionOutcome<WireOutput, Problem> => {
disposeLifetime(); disposeLifetime();
try { try {
dependencies.observe?.({ const status = observationStatus(outcome);
diagnosticsOperation: policy.diagnosticsOperation, dependencies.observe?.(
outcome: outcome.kind, Object.freeze({
attempts, routeId: context.routeId,
certainty, operationId: contract.operationId,
}); diagnosticsOperation: policy.diagnosticsOperation,
outcome: outcome.kind,
errorKind: observationErrorKind(outcome, terminalReason),
...(status === undefined ? {} : { status }),
attemptCount: attempts,
durationMs: Math.max(0, now() - startedAt),
effect: outcome.effect,
terminalReason,
...(terminalCancellation === null
? {}
: { cancellationOwner: terminalCancellation }),
}),
);
} catch { } catch {
// Observation is outside the execution authority. // Observation is outside the execution authority.
} }
+81 -19
View File
@@ -6,7 +6,17 @@ import {
} from "../adapters/auth/external-session-adapter.ts"; } from "../adapters/auth/external-session-adapter.ts";
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts"; import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts";
import { createHttpClient } from "../adapters/http/client.ts"; import { createHttpClient } from "../adapters/http/client.ts";
import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts"; import {
createContractHttpExecutor,
type HttpExecutionObservation,
} from "../adapters/http/http-execution-v3.ts";
import {
attemptBucket,
durationBucket,
statusGroup,
type DiagnosticRecordInput,
} from "../contracts/diagnostics.ts";
import type { TelemetryEventName } from "../contracts/telemetry.ts";
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts"; import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
import { import {
createTanStackCacheCoordinator, createTanStackCacheCoordinator,
@@ -162,6 +172,72 @@ export function createRuntimeHttpClient(
}); });
} }
/**
* VD-07. Exactly one diagnostic per logical V3 execution and exactly one
* `api.request.failed` telemetry event per terminal non-abort failure.
*
* The projection is closed: only registered context keys and bucketed values
* reach the sinks, and neither sink can change the HTTP outcome, because the
* caller invokes this inside the executor's isolated observation boundary.
*/
export function createHttpObservationProjector(
sinks: Readonly<{
diagnostics: Readonly<{ record(input: DiagnosticRecordInput): void }>;
telemetry: Readonly<{
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void;
}>;
}>,
): (observation: HttpExecutionObservation) => void {
return (observation) => {
const safeAttributes = {
route_id: observation.routeId,
operation_id: observation.operationId,
error_kind: observation.errorKind,
http_status_group: statusGroup(observation.status),
attempt_count_bucket: attemptBucket(observation.attemptCount),
duration_bucket: durationBucket(observation.durationMs),
};
try {
sinks.diagnostics.record({
level: observation.outcome === "SUCCESS" ? "info" : "warn",
eventId: "http.request.completed",
context: {
...safeAttributes,
operation: observation.diagnosticsOperation,
outcome: observation.outcome,
},
});
} catch {
// Diagnostics cannot change a contract execution outcome.
}
if (!isTerminalNonAbortFailure(observation)) return;
try {
sinks.telemetry.emit("api.request.failed", { ...safeAttributes });
} catch {
// Telemetry cannot change a contract execution outcome.
}
};
}
/**
* Cancellation and scope fencing are caller- or generation-owned decisions, not
* API failures. They produce a diagnostic once and never `api.request.failed`.
*/
function isTerminalNonAbortFailure(
observation: HttpExecutionObservation,
): boolean {
if (observation.outcome === "SUCCESS") return false;
if (observation.outcome === "CANCELLED") return false;
if (observation.cancellationOwner !== undefined) return false;
return !(
observation.outcome === "CONTRACT_VIOLATION" &&
observation.errorKind === "SCOPE_FENCED"
);
}
export async function createRuntimeAdapters( export async function createRuntimeAdapters(
context: RuntimeAdaptersContext, context: RuntimeAdaptersContext,
) { ) {
@@ -328,32 +404,17 @@ export async function createRuntimeAdapters(
return Object.freeze({ kind: "UNAVAILABLE" as const }); return Object.freeze({ kind: "UNAVAILABLE" as const });
} }
}, },
observe(observation) { observe: createHttpObservationProjector({ diagnostics, telemetry }),
try {
diagnostics.record({
level:
observation.outcome === "SUCCESS" ? "info" : "warn",
eventId: "http.request.completed",
context: {
operation_id: observation.diagnosticsOperation,
outcome: observation.outcome,
attempts: observation.attempts,
certainty: observation.certainty,
},
});
} catch {
// Diagnostics cannot change a contract execution outcome.
}
},
}); });
const contractOperations = Object.freeze({ const contractOperations = Object.freeze({
async execute( async execute(
operationId: string, operationId: string,
input: unknown, input: unknown,
executionContext: Readonly<{ executionContext: Readonly<{
routeId: string;
signal?: AbortSignal; signal?: AbortSignal;
intent?: MutationIntent; intent?: MutationIntent;
}> = {}, }>,
) { ) {
const operation = const operation =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId); COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
@@ -368,6 +429,7 @@ export async function createRuntimeAdapters(
}); });
} }
const outcome = await contractHttp.execute(operation, input, { const outcome = await contractHttp.execute(operation, input, {
routeId: executionContext.routeId,
scope: serverStateScope.getSnapshot(), scope: serverStateScope.getSnapshot(),
...(executionContext.signal === undefined ...(executionContext.signal === undefined
? {} ? {}
@@ -23,7 +23,8 @@ export type InstalledContractOperationExecutor = Readonly<{
execute( execute(
operationId: string, operationId: string,
input: unknown, input: unknown,
context?: Readonly<{ context: Readonly<{
routeId: string;
signal?: AbortSignal; signal?: AbortSignal;
intent?: MutationIntent; intent?: MutationIntent;
}>, }>,
@@ -48,6 +49,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
operationId, operationId,
input, input,
{ {
// §7.4. The gateway owns the low-cardinality route identity; losing
// it here is what made every V3 diagnostic unattributable.
routeId: request.routeId,
...(signal === undefined ? {} : { signal }), ...(signal === undefined ? {} : { signal }),
...(intent === undefined ? {} : { intent }), ...(intent === undefined ? {} : { intent }),
}, },
@@ -98,7 +98,7 @@ describe("HTTP operation execution contract", () => {
executor.execute( executor.execute(
nonKeyedCommand, nonKeyedCommand,
{ name: "created" }, { name: "created" },
{ scope, intent }, { routeId: "TEST_ROUTE", scope, intent },
), ),
).resolves.toMatchObject({ kind: "SUCCESS" }); ).resolves.toMatchObject({ kind: "SUCCESS" });
expect(observedKeys).toEqual([null]); expect(observedKeys).toEqual([null]);
@@ -109,7 +109,7 @@ describe("HTTP operation execution contract", () => {
executor.execute( executor.execute(
nonKeyedCommand, nonKeyedCommand,
{ name: "created" }, { name: "created" },
{ scope, intent: { ...intent, idempotencyKey: "unexpected-key" } }, { routeId: "TEST_ROUTE", scope, intent: { ...intent, idempotencyKey: "unexpected-key" } },
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION", kind: "CONTRACT_VIOLATION",
@@ -0,0 +1,319 @@
import { describe, expect, it, vi } from "vitest";
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
import type {
HttpExecutionObservation,
} from "../../src/adapters/http/http-execution-v3.ts";
import { createHttpObservationProjector } from "../../src/bootstrap/runtime-adapters.ts";
import { createReferenceFeatureInstalledInput } from "../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
import {
DIAGNOSTIC_CONTEXT_ALLOWLIST,
projectDiagnosticRecord,
type DiagnosticRecordInput,
} from "../../src/contracts/diagnostics.ts";
import {
projectTelemetryEvent,
type TelemetryEventName,
} from "../../src/contracts/telemetry.ts";
import {
TEST_CREATE_HTTP_CONTRACT,
TEST_LIST_HTTP_CONTRACT,
} from "../helpers/external-contract-fixture.ts";
const ROUTE_ID = "TEST_ROUTE";
function scopeSnapshot(isCurrent: () => boolean = () => true) {
return Object.freeze({
generation: 1,
fingerprint: "scope-1",
identities: Object.freeze({}) as never,
signal: new AbortController().signal,
isCurrent,
});
}
type RecordedDiagnostic = DiagnosticRecordInput;
type RecordedTelemetry = Readonly<{
eventName: TelemetryEventName;
attributes: Readonly<Record<string, unknown>>;
}>;
function recordingSinks() {
const diagnostics: RecordedDiagnostic[] = [];
const telemetry: RecordedTelemetry[] = [];
return {
diagnostics,
telemetry,
projector: createHttpObservationProjector({
diagnostics: {
record(input: DiagnosticRecordInput) {
diagnostics.push(input);
},
},
telemetry: {
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
) {
telemetry.push(Object.freeze({ eventName, attributes }));
},
},
}),
};
}
describe("V3 HTTP observability projection", () => {
it("projects every V3 terminal outcome through the closed diagnostics allowlist", async () => {
const sinks = recordingSinks();
const cases: readonly Readonly<{
label: string;
fetcher: typeof fetch;
}>[] = [
{
label: "SUCCESS",
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
},
{
label: "PROBLEM",
fetcher: (async () =>
Response.json(
{ type: "about:blank", title: "nope", status: 400 },
{ status: 400 },
)) as unknown as typeof fetch,
},
{
label: "TRANSPORT_FAILURE",
fetcher: (async () => {
throw new TypeError("network down");
}) as unknown as typeof fetch,
},
{
label: "CONTRACT_VIOLATION",
fetcher: (async () =>
new Response("<html/>", {
status: 200,
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch,
},
];
for (const testCase of cases) {
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: testCase.fetcher,
observe: sinks.projector,
});
await executor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
}
expect(sinks.diagnostics).toHaveLength(cases.length);
for (const recorded of sinks.diagnostics) {
expect(recorded.eventId).toBe("http.request.completed");
const contextKeys = Object.keys(recorded.context ?? {});
expect(contextKeys).toEqual(
expect.arrayContaining([
"route_id",
"operation_id",
"operation",
"outcome",
"error_kind",
"http_status_group",
"attempt_count_bucket",
"duration_bucket",
]),
);
for (const key of contextKeys) {
expect(DIAGNOSTIC_CONTEXT_ALLOWLIST).toContain(key);
}
expect(contextKeys).not.toContain("attempts");
expect(contextKeys).not.toContain("certainty");
const projection = projectDiagnosticRecord(recorded);
expect(projection.success).toBe(true);
}
});
it("emits one failure telemetry event for a non-abort terminal failure", async () => {
const sinks = recordingSinks();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () => {
throw new TypeError("network down");
}) as unknown as typeof fetch,
observe: sinks.projector,
});
const outcome = await executor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
expect(sinks.telemetry).toHaveLength(1);
const emitted = sinks.telemetry[0];
expect(emitted?.eventName).toBe("api.request.failed");
expect(emitted?.attributes).toMatchObject({
route_id: ROUTE_ID,
operation_id: "TEST_LIST_ENTITIES",
error_kind: "NETWORK_FAILURE",
http_status_group: "none",
attempt_count_bucket: "1",
});
const projected = projectTelemetryEvent(
emitted?.eventName ?? "api.request.failed",
emitted?.attributes ?? {},
);
expect(projected.success).toBe(true);
expect(sinks.diagnostics).toHaveLength(1);
});
it("does not emit failure telemetry for caller cancellation or scope fencing", async () => {
const cancelled = recordingSinks();
const callerController = new AbortController();
callerController.abort();
const cancelledExecutor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
observe: cancelled.projector,
});
const cancelledOutcome = await cancelledExecutor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{
routeId: ROUTE_ID,
scope: scopeSnapshot(),
signal: callerController.signal,
},
);
expect(cancelledOutcome.kind).toBe("CANCELLED");
expect(cancelled.diagnostics).toHaveLength(1);
expect(cancelled.telemetry).toHaveLength(0);
const fenced = recordingSinks();
const fencedExecutor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
observe: fenced.projector,
});
const fencedOutcome = await fencedExecutor.execute(
TEST_LIST_HTTP_CONTRACT,
{ limit: 5 },
{ routeId: ROUTE_ID, scope: scopeSnapshot(() => false) },
);
expect(fencedOutcome.kind).toBe("CONTRACT_VIOLATION");
expect(fenced.diagnostics).toHaveLength(1);
expect(fenced.telemetry).toHaveLength(0);
});
it("preserves the feature route id through the installed operation executor", async () => {
const seen: Array<Readonly<Record<string, unknown>>> = [];
const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({
async execute(
_operationId: string,
_input: unknown,
context?: Readonly<Record<string, unknown>>,
) {
seen.push(Object.freeze({ ...(context ?? {}) }));
return Object.freeze({
kind: "SUCCESS" as const,
value: [],
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
}),
});
await installed.input.listResources({ limit: 20 });
await installed.input.getResource("resource-1");
expect(seen.map((context) => context.routeId)).toEqual([
"REFERENCE_RESOURCE_LIST",
"REFERENCE_RESOURCE_DETAIL",
]);
});
it("cannot change the HTTP result when diagnostics or telemetry throws", async () => {
const projector = createHttpObservationProjector({
diagnostics: {
record() {
throw new Error("diagnostics sink exploded");
},
},
telemetry: {
emit() {
throw new Error("telemetry sink exploded");
},
},
});
const observe = vi.fn((observation: HttpExecutionObservation) => {
projector(observation);
});
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () =>
Response.json({ id: "created", name: "Created" }, {
status: 201,
})) as unknown as typeof fetch,
observe,
});
const outcome = await executor.execute(
TEST_CREATE_HTTP_CONTRACT,
{ name: "Created" },
{
routeId: ROUTE_ID,
scope: scopeSnapshot(),
intent: Object.freeze({
intentId: "intent-1",
operationId: "TEST_CREATE_ENTITY",
canonicalInputIdentity: "opaque-input-identity",
idempotencyKey: "key-1",
createdAtMonotonicMs: 1,
}),
},
);
expect(outcome.kind).toBe("SUCCESS");
expect(observe).toHaveBeenCalledTimes(1);
});
});
+17 -10
View File
@@ -4,7 +4,10 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readBoundedBytes } from "../../src/adapters/http/bounded-body-reader.ts"; import { readBoundedBytes } from "../../src/adapters/http/bounded-body-reader.ts";
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts"; import {
createContractHttpExecutor,
type HttpExecutionObservation,
} from "../../src/adapters/http/http-execution-v3.ts";
import { import {
type InstalledHttpContract, type InstalledHttpContract,
} from "../../src/contracts/external-contract-runtime.ts"; } from "../../src/contracts/external-contract-runtime.ts";
@@ -30,6 +33,13 @@ import {
type HttpScenarioOperationId, type HttpScenarioOperationId,
} from "../mocks/scenarios/catalog.ts"; } from "../mocks/scenarios/catalog.ts";
/** The reference gateway owns these low-cardinality route identities. */
function routeIdFor(operationId: HttpScenarioOperationId): string {
return operationId === "GET_REFERENCE_RESOURCE"
? "REFERENCE_RESOURCE_DETAIL"
: "REFERENCE_RESOURCE_LIST";
}
const RECEIPT_PATH = path.resolve( const RECEIPT_PATH = path.resolve(
"artifacts/tests/http-scenario-executions.json", "artifacts/tests/http-scenario-executions.json",
); );
@@ -191,11 +201,7 @@ async function executeScenario(
): Promise<HttpScenarioAssertionGroups> { ): Promise<HttpScenarioAssertionGroups> {
const physicalAttempts: AttemptTrace[] = []; const physicalAttempts: AttemptTrace[] = [];
const sleeps: RetryReason[] = []; const sleeps: RetryReason[] = [];
const observations: Array<Readonly<{ const observations: HttpExecutionObservation[] = [];
outcome: string;
attempts: number;
certainty: string;
}>> = [];
const caller = new AbortController(); const caller = new AbortController();
const scopeLifetime = new AbortController(); const scopeLifetime = new AbortController();
let scopeCurrent = true; let scopeCurrent = true;
@@ -265,6 +271,7 @@ async function executeScenario(
try { try {
const execution = executor.execute(operation, inputFor(entry.operationId), { const execution = executor.execute(operation, inputFor(entry.operationId), {
routeId: routeIdFor(entry.operationId),
scope, scope,
signal: caller.signal, signal: caller.signal,
...(entry.operationId === "CREATE_REFERENCE_RESOURCE" ...(entry.operationId === "CREATE_REFERENCE_RESOURCE"
@@ -296,7 +303,7 @@ async function executeScenario(
const observation = observations[0]!; const observation = observations[0]!;
const observedSignal = scopeLifetime.signal.aborted ? "ABORTED" : "ACTIVE"; const observedSignal = scopeLifetime.signal.aborted ? "ABORTED" : "ACTIVE";
const cancellationOwner = const cancellationOwner =
observation.certainty === "TIMEOUT" observation.terminalReason === "TIMEOUT"
? "DEADLINE" ? "DEADLINE"
: caller.signal.aborted : caller.signal.aborted
? "CALLER" ? "CALLER"
@@ -314,13 +321,13 @@ async function executeScenario(
}), }),
effect: Object.freeze({ effect: Object.freeze({
outcome: String(outcome.effect), outcome: String(outcome.effect),
observer: observation.certainty, observer: observation.terminalReason,
}), }),
retry: Object.freeze({ count: sleeps.length, reasons: Object.freeze(sleeps) }), retry: Object.freeze({ count: sleeps.length, reasons: Object.freeze(sleeps) }),
fetch: Object.freeze({ fetch: Object.freeze({
count: physicalAttempts.length, count: physicalAttempts.length,
observerAttempts: observation.attempts, observerAttempts: observation.attemptCount,
agrees: physicalAttempts.length === observation.attempts, agrees: physicalAttempts.length === observation.attemptCount,
}), }),
media: Object.freeze({ media: Object.freeze({
attempts: Object.freeze(physicalAttempts.map((attempt) => attempt.media)), attempts: Object.freeze(physicalAttempts.map((attempt) => attempt.media)),
+27 -15
View File
@@ -10,6 +10,8 @@ import {
const installed: InstalledHttpContract<unknown, unknown, unknown> = const installed: InstalledHttpContract<unknown, unknown, unknown> =
TEST_LIST_HTTP_CONTRACT; TEST_LIST_HTTP_CONTRACT;
const ROUTE_ID = "TEST_ROUTE";
const scope = Object.freeze({ const scope = Object.freeze({
generation: 1, generation: 1,
fingerprint: "scope-1", fingerprint: "scope-1",
@@ -82,7 +84,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
}); });
await expect( await expect(
executor.execute(installed, { limit: 20 }, { scope }), executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
kind: "RATE_LIMITED", kind: "RATE_LIMITED",
effect: "NOT_APPLICABLE", effect: "NOT_APPLICABLE",
@@ -119,7 +121,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
executor.execute( executor.execute(
createInstalled, createInstalled,
{ name: "created" }, { name: "created" },
{ scope, ...(intent === undefined ? {} : { intent }) }, { routeId: ROUTE_ID, scope, ...(intent === undefined ? {} : { intent }) },
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION", kind: "CONTRACT_VIOLATION",
@@ -154,7 +156,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
}); });
await expect( await expect(
executor.execute(installed, { limit: 20 }, { scope, intent }), executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, intent }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION", kind: "CONTRACT_VIOLATION",
violation: { violation: {
@@ -173,14 +175,14 @@ describe("descriptor-driven HTTP execution lifetime", () => {
label: "query with the canonical reserved header", label: "query with the canonical reserved header",
operation: installed, operation: installed,
input: { limit: 20 }, input: { limit: 20 },
context: { scope }, context: { routeId: ROUTE_ID, scope },
headerName: "Idempotency-Key", headerName: "Idempotency-Key",
}, },
{ {
label: "valid KEYED command with a case-variant reserved header", label: "valid KEYED command with a case-variant reserved header",
operation: createInstalled, operation: createInstalled,
input: { name: "created" }, input: { name: "created" },
context: { scope, intent: mutationIntent() }, context: { routeId: ROUTE_ID, scope, intent: mutationIntent() },
headerName: "iDeMpOtEnCy-KeY", headerName: "iDeMpOtEnCy-KeY",
}, },
])( ])(
@@ -252,7 +254,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
executor.execute( executor.execute(
retryingCreate, retryingCreate,
{ name: "created" }, { name: "created" },
{ scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) }, { routeId: ROUTE_ID, scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) },
), ),
).resolves.toMatchObject({ kind: "SUCCESS" }); ).resolves.toMatchObject({ kind: "SUCCESS" });
expect(fetcher).toHaveBeenCalledTimes(2); expect(fetcher).toHaveBeenCalledTimes(2);
@@ -284,7 +286,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
executor.execute( executor.execute(
installed, installed,
{ limit: 20 }, { limit: 20 },
{ scope }, { routeId: ROUTE_ID, scope },
), ),
).resolves.toMatchObject({ kind: "SUCCESS" }); ).resolves.toMatchObject({ kind: "SUCCESS" });
expect(fetcher).toHaveBeenCalledOnce(); expect(fetcher).toHaveBeenCalledOnce();
@@ -317,6 +319,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
createInstalled, createInstalled,
{ name: "created" }, { name: "created" },
{ {
routeId: ROUTE_ID,
scope, scope,
intent: Object.freeze({ intent: Object.freeze({
intentId: "private-intent-id", intentId: "private-intent-id",
@@ -353,7 +356,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
fetcher, fetcher,
}); });
await expect(executor.execute(installed, input, { scope })).resolves.toMatchObject({ await expect(executor.execute(installed, input, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({
kind: "SUCCESS", kind: "SUCCESS",
}); });
expect(String((fetcher.mock.calls as unknown[][])[0]?.[0])).toContain( expect(String((fetcher.mock.calls as unknown[][])[0]?.[0])).toContain(
@@ -389,11 +392,11 @@ describe("descriptor-driven HTTP execution lifetime", () => {
}, },
} as unknown as typeof installed; } as unknown as typeof installed;
await expect(executor.execute(throwing, { limit: 20 }, { scope })).resolves.toMatchObject({ await expect(executor.execute(throwing, { limit: 20 }, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION", kind: "CONTRACT_VIOLATION",
effect: "NOT_APPLICABLE", effect: "NOT_APPLICABLE",
}); });
await expect(executor.execute(malformed, { limit: 20 }, { scope })).resolves.toMatchObject({ await expect(executor.execute(malformed, { limit: 20 }, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION", kind: "CONTRACT_VIOLATION",
effect: "NOT_APPLICABLE", effect: "NOT_APPLICABLE",
}); });
@@ -416,6 +419,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
createInstalled, createInstalled,
{ name: "created" }, { name: "created" },
{ {
routeId: ROUTE_ID,
scope, scope,
intent: mutationIntent(), intent: mutationIntent(),
}, },
@@ -458,6 +462,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
createInstalled, createInstalled,
{ name: "created" }, { name: "created" },
{ {
routeId: ROUTE_ID,
scope: fencedScope, scope: fencedScope,
intent: mutationIntent({ intentId: "intent-2", idempotencyKey: "key-2" }), intent: mutationIntent({ intentId: "intent-2", idempotencyKey: "key-2" }),
}, },
@@ -479,7 +484,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
}); });
const result = executor const result = executor
.execute(operation({ deadlineMs: 5 }), { limit: 20 }, { scope }) .execute(operation({ deadlineMs: 5 }), { limit: 20 }, { routeId: ROUTE_ID, scope })
.then((outcome) => { .then((outcome) => {
settled = true; settled = true;
return outcome; return outcome;
@@ -532,7 +537,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
const result = executor.execute( const result = executor.execute(
operation({ deadlineMs: 5 }), operation({ deadlineMs: 5 }),
{ limit: 20 }, { limit: 20 },
{ scope }, { routeId: ROUTE_ID, scope },
); );
await vi.advanceTimersByTimeAsync(5); await vi.advanceTimersByTimeAsync(5);
await flushMicrotasks(); await flushMicrotasks();
@@ -548,7 +553,14 @@ describe("descriptor-driven HTTP execution lifetime", () => {
expect(observe).toHaveBeenCalledTimes(iterationCount); expect(observe).toHaveBeenCalledTimes(iterationCount);
for (const [observation] of observe.mock.calls) { for (const [observation] of observe.mock.calls) {
expect(observation).toEqual( expect(observation).toEqual(
expect.objectContaining({ attempts: 1, certainty: "TIMEOUT" }), expect.objectContaining({
attemptCount: 1,
errorKind: "TIMEOUT",
terminalReason: "TIMEOUT",
cancellationOwner: "DEADLINE",
routeId: ROUTE_ID,
operationId: "TEST_LIST_ENTITIES",
}),
); );
} }
} finally { } finally {
@@ -582,7 +594,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
}); });
const result = executor const result = executor
.execute(installed, { limit: 20 }, { scope, signal: caller.signal }) .execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, signal: caller.signal })
.then((outcome) => { .then((outcome) => {
settled = true; settled = true;
return outcome; return outcome;
@@ -617,7 +629,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
executor.execute( executor.execute(
operation({ responseBody: "NONE" }), operation({ responseBody: "NONE" }),
{ limit: 20 }, { limit: 20 },
{ scope }, { routeId: ROUTE_ID, scope },
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
kind: "TRANSPORT_FAILURE", kind: "TRANSPORT_FAILURE",