Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion connectors/producer-polar/test/api.vcr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ describe("producer-polar api (vcr)", () => {
const cassetteLayer = CassetteStoreLive.pipe(
Layer.provide(NodeFileSystem.layer),
);
const vcrLayer = VcrHttpClientLayer().pipe(
const vcrLayer = VcrHttpClientLayer({
connectorName: "producer-polar",
mode: "replay",
}).pipe(
Layer.provide(Layer.mergeAll(NodeHttpClient.layerFetch, cassetteLayer)),
);

Expand Down
5 changes: 5 additions & 0 deletions packages/effect-http-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const runnable = program.pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provide(
VcrHttpClientLayer({
connectorName: "producer-polar",
cassetteDir: "./cassettes",
cassetteName: "example",
mode: "auto",
Expand Down Expand Up @@ -62,6 +63,7 @@ const runnable = program.pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provide(
VcrHttpClientLayer({
connectorName: "producer-polar",
cassetteDir: "./cassettes",
cassetteName: "example",
mode: "auto",
Expand Down Expand Up @@ -99,6 +101,7 @@ export interface CassetteStoreService {
type VcrMode = "record" | "replay" | "auto";

type VcrConfig = {
connectorName: string;
cassetteDir: string;
cassetteName: string;
mode: VcrMode;
Expand Down Expand Up @@ -137,3 +140,5 @@ Notes:

- Request body streams are not consumed; they are represented as `"[stream]"`.
- CI detection uses Effect Config (`Config.boolean("CI")`), so you can override it with a `ConfigProvider`.
- Connector-selective bypass is supported via `ACK_DISABLE_VCR` (comma-separated connector slugs).
- Set `connectorName` in `VcrConfig` to enable connector-specific bypass matching against `ACK_DISABLE_VCR`.
1 change: 1 addition & 0 deletions packages/effect-http-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type VcrCassetteFile = {
* VCR configuration.
*/
export type VcrConfig = {
readonly connectorName: string;
readonly cassetteDir?: string;
readonly cassetteName?: string;
readonly mode?: VcrMode;
Expand Down
171 changes: 103 additions & 68 deletions packages/effect-http-client/src/vcr-http-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Config, Effect } from "effect";
import { Config, Effect, Option } from "effect";
import {
HttpClient,
HttpClientError,
Expand Down Expand Up @@ -78,6 +78,7 @@ const resolveCassetteLocation = (config: VcrConfig) => {
* Apply defaults for common VCR behavior while preserving explicit overrides.
*/
const normalizeConfig = (config: VcrConfig) => ({
connectorName: config.connectorName,
cassetteDir: config.cassetteDir,
cassetteName: config.cassetteName,
mode: config.mode ?? "auto",
Expand All @@ -92,6 +93,31 @@ const normalizeConfig = (config: VcrConfig) => ({
match: config.match,
});

const AckDisableVcrConfig = Config.option(
Config.string("ACK_DISABLE_VCR"),
).pipe(
Config.map((value) =>
Option.match(value, {
onNone: () => new Set<string>(),
onSome: (raw) =>
new Set(
raw
.split(",")
.map((segment) => segment.trim().toLowerCase())
.filter((segment) => segment.length > 0),
),
}),
),
);

const shouldDisableVcr = (options: {
readonly connectorName: string;
readonly disabledConnectors: ReadonlySet<string>;
}): boolean => {
const normalized = options.connectorName.trim().toLowerCase();
return options.disabledConnectors.has(normalized);
};

/**
* Convert store or replay failures into HttpClient transport errors.
*/
Expand Down Expand Up @@ -365,23 +391,87 @@ export const makeVcrHttpClient = (
const normalized = normalizeConfig(config);
const { path, exportKey } = resolveCassetteLocation(normalized);
const isCi = yield* Config.boolean("CI").pipe(Config.withDefault(false));
const disabledVcrConnectors = yield* AckDisableVcrConfig;
const vcrDisabledForConnector = shouldDisableVcr({
connectorName: normalized.connectorName,
disabledConnectors: disabledVcrConnectors,
});

Comment thread
fracek marked this conversation as resolved.
const client = live.pipe(
HttpClient.transform((effect, request) => {
const vcrRequest = toVcrRequest(request);
if (normalized.mode === "replay") {
return replay(
request,
vcrRequest,
normalized,
store,
return Effect.gen(function* () {
if (vcrDisabledForConnector) {
return yield* effect;
}

const vcrRequest = toVcrRequest(request);
if (normalized.mode === "replay") {
return yield* replay(
request,
vcrRequest,
normalized,
store,
path,
exportKey,
);
}

if (normalized.mode === "record") {
return yield* record(
request,
vcrRequest,
effect,
normalized,
store,
path,
exportKey,
);
}

// Auto mode: replay if cassette exists, otherwise record (or fail in CI).
const available = yield* store
.exists(path)
.pipe(Effect.mapError((error) => toRequestError(request, error)));

if (!available) {
if (isCi) {
return yield* Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
description: "VCR cassette missing in CI for auto mode",
}),
}),
);
}
return yield* record(
request,
vcrRequest,
effect,
normalized,
store,
path,
exportKey,
);
}

const cassette = yield* readCassetteExport(
path,
exportKey,
store,
request,
);
}
const entry = yield* findEntry(vcrRequest, cassette, normalized);

if (normalized.mode === "record") {
return record(
if (entry) {
const web = new Response(entry.response.body, {
status: entry.response.status,
headers: entry.response.headers,
});
return HttpClientResponse.fromWeb(request, web);
}

return yield* record(
request,
vcrRequest,
effect,
Expand All @@ -390,62 +480,7 @@ export const makeVcrHttpClient = (
path,
exportKey,
);
}

// Auto mode: replay if cassette exists, otherwise record (or fail in CI).
return store.exists(path).pipe(
Effect.mapError((error) => toRequestError(request, error)),
Effect.flatMap((available) => {
if (!available) {
if (isCi) {
return Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
description: "VCR cassette missing in CI for auto mode",
}),
}),
);
}
return record(
request,
vcrRequest,
effect,
normalized,
store,
path,
exportKey,
);
}

return readCassetteExport(path, exportKey, store, request).pipe(
Effect.flatMap((cassette) =>
findEntry(vcrRequest, cassette, normalized).pipe(
Effect.flatMap((entry) => {
if (entry) {
const web = new Response(entry.response.body, {
status: entry.response.status,
headers: entry.response.headers,
});
return Effect.succeed(
HttpClientResponse.fromWeb(request, web),
);
}
return record(
request,
vcrRequest,
effect,
normalized,
store,
path,
exportKey,
);
}),
),
),
);
}),
);
});
}),
);

Expand All @@ -455,7 +490,7 @@ export const makeVcrHttpClient = (
/**
* Layer that provides a VCR-wrapped HttpClient.
*/
export const layer = (config: VcrConfig = {}) =>
export const layer = (config: VcrConfig) =>
HttpClient.layerMergedServices(
Effect.gen(function* () {
const live = yield* HttpClient.HttpClient;
Expand Down
72 changes: 72 additions & 0 deletions packages/effect-http-client/test/vcr-http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
describe("record mode", () => {
it.effect("stores a cassette entry", () => {
const config: VcrConfig = {
connectorName: "test-connector",
cassetteDir: "/tmp/vcr",
cassetteName: "record-basic.cassette",
mode: "record",
Expand Down Expand Up @@ -45,6 +46,7 @@ describe("record mode", () => {
describe("replay mode", () => {
it.effect("returns stored response without live client", () => {
const config: VcrConfig = {
connectorName: "test-connector",
cassetteDir: "/tmp/vcr",
cassetteName: "replay-basic.cassette",
mode: "replay",
Expand Down Expand Up @@ -98,6 +100,7 @@ describe("replay mode", () => {
describe("auto mode", () => {
it.effect("replays when cassette exists", () => {
const config: VcrConfig = {
connectorName: "test-connector",
cassetteDir: "/tmp/vcr",
cassetteName: "auto-replay.cassette",
mode: "auto",
Expand Down Expand Up @@ -147,6 +150,7 @@ describe("auto mode", () => {
describe("record with redaction", () => {
it.effect("removes sensitive data from stored cassette", () => {
const config: VcrConfig = {
connectorName: "test-connector",
cassetteDir: "/tmp/vcr",
cassetteName: "redact-ignore.cassette",
mode: "record",
Expand Down Expand Up @@ -192,6 +196,7 @@ describe("record with redaction", () => {
describe("auto mode in CI", () => {
it.effect("fails when cassette is missing", () => {
const config: VcrConfig = {
connectorName: "test-connector",
cassetteDir: "/tmp/vcr",
cassetteName: "auto-ci-miss.cassette",
mode: "auto",
Expand All @@ -218,3 +223,70 @@ describe("auto mode in CI", () => {
);
});
});

describe("ACK_DISABLE_VCR with connectorName", () => {
it.effect(
"bypasses VCR in replay mode when connectorName is disabled",
() => {
const config: VcrConfig = {
connectorName: "producer-polar",
cassetteDir: "/tmp/vcr",
cassetteName: "context-disable.cassette",
mode: "replay",
};
const { layer: storeLayer } = makeStoreLayer();
const live = makeLiveClient("live-response");

const liveLayer = Layer.succeed(HttpClient.HttpClient)(live);
const vcrLayer = VcrHttpClientLayer(config).pipe(
Layer.provide(Layer.mergeAll(storeLayer, liveLayer)),
);

return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
const response = yield* client.get("https://example.com/");
const text = yield* response.text;

expect(text).toBe("live-response");
}).pipe(
Effect.provide(vcrLayer),
Effect.provideService(
ConfigProvider.ConfigProvider,
ConfigProvider.fromUnknown({ ACK_DISABLE_VCR: "producer-polar" }),
),
);
},
);

it.effect(
"keeps VCR replay behavior when connectorName is not disabled",
() => {
const config: VcrConfig = {
connectorName: "producer-stripe",
cassetteDir: "/tmp/vcr",
cassetteName: "context-missing.cassette",
mode: "replay",
};
const { layer: storeLayer } = makeStoreLayer();
const live = makeLiveClient("live-response");

const liveLayer = Layer.succeed(HttpClient.HttpClient)(live);
const vcrLayer = VcrHttpClientLayer(config).pipe(
Layer.provide(Layer.mergeAll(storeLayer, liveLayer)),
);

return Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
const result = yield* Effect.exit(client.get("https://example.com/"));

expect(Exit.isFailure(result)).toBe(true);
}).pipe(
Effect.provide(vcrLayer),
Effect.provideService(
ConfigProvider.ConfigProvider,
ConfigProvider.fromUnknown({ ACK_DISABLE_VCR: "producer-polar" }),
),
);
},
);
});
Loading