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를 정확히 한 번 남긴다.
|
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 | — |
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user