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:
co-authored by
Claude Opus 5
parent
f7bec8274b
commit
67cc5b6d2c
@@ -37,6 +37,21 @@ vendor 결정 전에는 안전한 기본값이 아니다.
|
||||
abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다.
|
||||
`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`,
|
||||
`telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage
|
||||
실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다.
|
||||
@@ -79,6 +94,11 @@ route/application/HTTP/cache/storage/bootstrap
|
||||
queue full, sink/observer failure와 pre-mount boot evidence를 검증한다.
|
||||
- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer
|
||||
횟수, 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과
|
||||
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 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 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-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 | — |
|
||||
|
||||
@@ -134,6 +134,12 @@ export type CancellationOwner =
|
||||
| "DEADLINE";
|
||||
|
||||
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 scope: CacheScopeSnapshot;
|
||||
readonly intent?: MutationIntent;
|
||||
@@ -147,13 +153,70 @@ export interface ContractHttpExecutor {
|
||||
): 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<{
|
||||
routeId: string;
|
||||
operationId: string;
|
||||
diagnosticsOperation: string;
|
||||
outcome: string;
|
||||
attempts: number;
|
||||
certainty: string;
|
||||
outcome: HttpExecutionOutcome<unknown, unknown>["kind"];
|
||||
errorKind: 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<{
|
||||
baseUrl: string;
|
||||
/** §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,
|
||||
// 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();
|
||||
|
||||
let attemptState: PhysicalAttemptState = "PREPARING";
|
||||
@@ -353,16 +417,28 @@ export function createContractHttpExecutor(
|
||||
|
||||
const finish = (
|
||||
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
||||
certainty: string,
|
||||
terminalReason: string,
|
||||
): HttpExecutionOutcome<WireOutput, Problem> => {
|
||||
disposeLifetime();
|
||||
try {
|
||||
dependencies.observe?.({
|
||||
diagnosticsOperation: policy.diagnosticsOperation,
|
||||
outcome: outcome.kind,
|
||||
attempts,
|
||||
certainty,
|
||||
});
|
||||
const status = observationStatus(outcome);
|
||||
dependencies.observe?.(
|
||||
Object.freeze({
|
||||
routeId: context.routeId,
|
||||
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 {
|
||||
// Observation is outside the execution authority.
|
||||
}
|
||||
|
||||
@@ -6,7 +6,17 @@ import {
|
||||
} from "../adapters/auth/external-session-adapter.ts";
|
||||
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.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 {
|
||||
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(
|
||||
context: RuntimeAdaptersContext,
|
||||
) {
|
||||
@@ -328,32 +404,17 @@ export async function createRuntimeAdapters(
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
}
|
||||
},
|
||||
observe(observation) {
|
||||
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.
|
||||
}
|
||||
},
|
||||
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
||||
});
|
||||
const contractOperations = Object.freeze({
|
||||
async execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
executionContext: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}> = {},
|
||||
}>,
|
||||
) {
|
||||
const operation =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
|
||||
@@ -368,6 +429,7 @@ export async function createRuntimeAdapters(
|
||||
});
|
||||
}
|
||||
const outcome = await contractHttp.execute(operation, input, {
|
||||
routeId: executionContext.routeId,
|
||||
scope: serverStateScope.getSnapshot(),
|
||||
...(executionContext.signal === undefined
|
||||
? {}
|
||||
|
||||
@@ -23,7 +23,8 @@ export type InstalledContractOperationExecutor = Readonly<{
|
||||
execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
context?: Readonly<{
|
||||
context: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}>,
|
||||
@@ -48,6 +49,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
|
||||
operationId,
|
||||
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 }),
|
||||
...(intent === undefined ? {} : { intent }),
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("HTTP operation execution contract", () => {
|
||||
executor.execute(
|
||||
nonKeyedCommand,
|
||||
{ name: "created" },
|
||||
{ scope, intent },
|
||||
{ routeId: "TEST_ROUTE", scope, intent },
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "SUCCESS" });
|
||||
expect(observedKeys).toEqual([null]);
|
||||
@@ -109,7 +109,7 @@ describe("HTTP operation execution contract", () => {
|
||||
executor.execute(
|
||||
nonKeyedCommand,
|
||||
{ name: "created" },
|
||||
{ scope, intent: { ...intent, idempotencyKey: "unexpected-key" } },
|
||||
{ routeId: "TEST_ROUTE", scope, intent: { ...intent, idempotencyKey: "unexpected-key" } },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
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 {
|
||||
type InstalledHttpContract,
|
||||
} from "../../src/contracts/external-contract-runtime.ts";
|
||||
@@ -30,6 +33,13 @@ import {
|
||||
type HttpScenarioOperationId,
|
||||
} 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(
|
||||
"artifacts/tests/http-scenario-executions.json",
|
||||
);
|
||||
@@ -191,11 +201,7 @@ async function executeScenario(
|
||||
): Promise<HttpScenarioAssertionGroups> {
|
||||
const physicalAttempts: AttemptTrace[] = [];
|
||||
const sleeps: RetryReason[] = [];
|
||||
const observations: Array<Readonly<{
|
||||
outcome: string;
|
||||
attempts: number;
|
||||
certainty: string;
|
||||
}>> = [];
|
||||
const observations: HttpExecutionObservation[] = [];
|
||||
const caller = new AbortController();
|
||||
const scopeLifetime = new AbortController();
|
||||
let scopeCurrent = true;
|
||||
@@ -265,6 +271,7 @@ async function executeScenario(
|
||||
|
||||
try {
|
||||
const execution = executor.execute(operation, inputFor(entry.operationId), {
|
||||
routeId: routeIdFor(entry.operationId),
|
||||
scope,
|
||||
signal: caller.signal,
|
||||
...(entry.operationId === "CREATE_REFERENCE_RESOURCE"
|
||||
@@ -296,7 +303,7 @@ async function executeScenario(
|
||||
const observation = observations[0]!;
|
||||
const observedSignal = scopeLifetime.signal.aborted ? "ABORTED" : "ACTIVE";
|
||||
const cancellationOwner =
|
||||
observation.certainty === "TIMEOUT"
|
||||
observation.terminalReason === "TIMEOUT"
|
||||
? "DEADLINE"
|
||||
: caller.signal.aborted
|
||||
? "CALLER"
|
||||
@@ -314,13 +321,13 @@ async function executeScenario(
|
||||
}),
|
||||
effect: Object.freeze({
|
||||
outcome: String(outcome.effect),
|
||||
observer: observation.certainty,
|
||||
observer: observation.terminalReason,
|
||||
}),
|
||||
retry: Object.freeze({ count: sleeps.length, reasons: Object.freeze(sleeps) }),
|
||||
fetch: Object.freeze({
|
||||
count: physicalAttempts.length,
|
||||
observerAttempts: observation.attempts,
|
||||
agrees: physicalAttempts.length === observation.attempts,
|
||||
observerAttempts: observation.attemptCount,
|
||||
agrees: physicalAttempts.length === observation.attemptCount,
|
||||
}),
|
||||
media: Object.freeze({
|
||||
attempts: Object.freeze(physicalAttempts.map((attempt) => attempt.media)),
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
const installed: InstalledHttpContract<unknown, unknown, unknown> =
|
||||
TEST_LIST_HTTP_CONTRACT;
|
||||
|
||||
const ROUTE_ID = "TEST_ROUTE";
|
||||
|
||||
const scope = Object.freeze({
|
||||
generation: 1,
|
||||
fingerprint: "scope-1",
|
||||
@@ -82,7 +84,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(installed, { limit: 20 }, { scope }),
|
||||
executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope }),
|
||||
).resolves.toMatchObject({
|
||||
kind: "RATE_LIMITED",
|
||||
effect: "NOT_APPLICABLE",
|
||||
@@ -119,7 +121,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{ scope, ...(intent === undefined ? {} : { intent }) },
|
||||
{ routeId: ROUTE_ID, scope, ...(intent === undefined ? {} : { intent }) },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
@@ -154,7 +156,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(installed, { limit: 20 }, { scope, intent }),
|
||||
executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, intent }),
|
||||
).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
violation: {
|
||||
@@ -173,14 +175,14 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
label: "query with the canonical reserved header",
|
||||
operation: installed,
|
||||
input: { limit: 20 },
|
||||
context: { scope },
|
||||
context: { routeId: ROUTE_ID, scope },
|
||||
headerName: "Idempotency-Key",
|
||||
},
|
||||
{
|
||||
label: "valid KEYED command with a case-variant reserved header",
|
||||
operation: createInstalled,
|
||||
input: { name: "created" },
|
||||
context: { scope, intent: mutationIntent() },
|
||||
context: { routeId: ROUTE_ID, scope, intent: mutationIntent() },
|
||||
headerName: "iDeMpOtEnCy-KeY",
|
||||
},
|
||||
])(
|
||||
@@ -252,7 +254,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
retryingCreate,
|
||||
{ name: "created" },
|
||||
{ scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) },
|
||||
{ routeId: ROUTE_ID, scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) },
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "SUCCESS" });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
@@ -284,7 +286,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
installed,
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
{ routeId: ROUTE_ID, scope },
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "SUCCESS" });
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
@@ -317,6 +319,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope,
|
||||
intent: Object.freeze({
|
||||
intentId: "private-intent-id",
|
||||
@@ -353,7 +356,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
fetcher,
|
||||
});
|
||||
|
||||
await expect(executor.execute(installed, input, { scope })).resolves.toMatchObject({
|
||||
await expect(executor.execute(installed, input, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({
|
||||
kind: "SUCCESS",
|
||||
});
|
||||
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;
|
||||
|
||||
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",
|
||||
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",
|
||||
effect: "NOT_APPLICABLE",
|
||||
});
|
||||
@@ -416,6 +419,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope,
|
||||
intent: mutationIntent(),
|
||||
},
|
||||
@@ -458,6 +462,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope: fencedScope,
|
||||
intent: mutationIntent({ intentId: "intent-2", idempotencyKey: "key-2" }),
|
||||
},
|
||||
@@ -479,7 +484,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
const result = executor
|
||||
.execute(operation({ deadlineMs: 5 }), { limit: 20 }, { scope })
|
||||
.execute(operation({ deadlineMs: 5 }), { limit: 20 }, { routeId: ROUTE_ID, scope })
|
||||
.then((outcome) => {
|
||||
settled = true;
|
||||
return outcome;
|
||||
@@ -532,7 +537,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
const result = executor.execute(
|
||||
operation({ deadlineMs: 5 }),
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
{ routeId: ROUTE_ID, scope },
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await flushMicrotasks();
|
||||
@@ -548,7 +553,14 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
expect(observe).toHaveBeenCalledTimes(iterationCount);
|
||||
for (const [observation] of observe.mock.calls) {
|
||||
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 {
|
||||
@@ -582,7 +594,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
const result = executor
|
||||
.execute(installed, { limit: 20 }, { scope, signal: caller.signal })
|
||||
.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, signal: caller.signal })
|
||||
.then((outcome) => {
|
||||
settled = true;
|
||||
return outcome;
|
||||
@@ -617,7 +629,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
operation({ responseBody: "NONE" }),
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
{ routeId: ROUTE_ID, scope },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "TRANSPORT_FAILURE",
|
||||
|
||||
Reference in New Issue
Block a user