Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
176 changes: 106 additions & 70 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 @@ -360,28 +386,93 @@ export const makeVcrHttpClient = (
config: VcrConfig,
) =>
Effect.gen(function* () {
const normalized = normalizeConfig(config);
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.
if (vcrDisabledForConnector) {
return live;
}

// CassetteStore is provided via Layer for platform-specific persistence.
const store = yield* CassetteStore;
const normalized = normalizeConfig(config);
const { path, exportKey } = resolveCassetteLocation(normalized);
const isCi = yield* Config.boolean("CI").pipe(Config.withDefault(false));

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* () {
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 +481,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 +491,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