Skip to content

Commit db50260

Browse files
authored
fix: check strict mode before proxy attempt (#182)
## Summary Fixes a critical bug where `X-AIMock-Strict: true` had no effect in `--proxy-only` mode. The strict-mode 503 check was positioned after `proxyAndRecord()`, which returns early on success — making the strict path unreachable. Moves the strict-mode check before the proxy attempt in all 17 handler files (22 call sites total). When strict mode is active, unmatched requests immediately return 503 without attempting to proxy. ## Test plan - [x] New test: strict header prevents proxy in record mode (503 instead of proxying) - [x] Updated recorder.test.ts for new semantics - [x] 2929 tests pass, 0 fail - [x] `npx tsc --noEmit` clean
2 parents 71b1c95 + 4e9ae35 commit db50260

20 files changed

Lines changed: 672 additions & 241 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixed
66

7+
- **Strict mode checked before proxy attempt** — in `--proxy-only` mode, the `X-AIMock-Strict` header had no effect because `proxyAndRecord()` returned before the strict check. Now all 17 handlers check strict mode first: when strict + no fixture → 503 immediately, no proxy attempt
78
- **Helper utilities and error serialization** — hardened helper functions and error serialization paths for correctness and robustness
89
- **Journal and fixture-loader correctness** — fixed journal entry handling and fixture-loader edge cases
910
- **WebSocket handler consistency and strict-mode journal** — aligned WebSocket handler behavior and ensured strict-mode journal entries are recorded correctly

src/__tests__/recorder.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,7 @@ describe("recorder strict mode", () => {
692692
expect(body.error.message).toBe("Strict mode: no fixture matched");
693693
});
694694

695-
it("record + strict: proxy succeeds when upstream is available", async () => {
695+
it("record + strict: strict blocks proxy even when upstream is available", async () => {
696696
await setupUpstreamAndRecorder([
697697
{
698698
match: { userMessage: "hello" },
@@ -714,14 +714,15 @@ describe("recorder strict mode", () => {
714714
record: { providers: { openai: upstream!.url }, fixturePath: tmpDir },
715715
});
716716

717+
// Strict mode now takes precedence over proxy — returns 503
717718
const resp = await post(`${recorder.url}/v1/chat/completions`, {
718719
model: "gpt-4",
719720
messages: [{ role: "user", content: "hello" }],
720721
});
721722

722-
expect(resp.status).toBe(200);
723+
expect(resp.status).toBe(503);
723724
const body = JSON.parse(resp.body);
724-
expect(body.choices[0].message.content).toBe("world");
725+
expect(body.error.message).toBe("Strict mode: no fixture matched");
725726
});
726727
});
727728

src/__tests__/strict-header.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,19 @@ describe("X-AIMock-Strict header integration", () => {
219219
expect(entries.length).toBe(1);
220220
expect(entries[0].response.strictOverride).toBe(false);
221221
});
222+
223+
it("strict header prevents proxy in record mode", async () => {
224+
server = await createServer([], {
225+
port: 0,
226+
record: {
227+
providers: { openai: "https://api.openai.com" },
228+
},
229+
});
230+
const res = await httpPost(`${server.url}/v1/chat/completions`, chatRequest("anything"), {
231+
"X-AIMock-Strict": "true",
232+
});
233+
expect(res.status).toBe(503);
234+
const body = JSON.parse(res.body);
235+
expect(body.error.message).toBe("Strict mode: no fixture matched");
236+
});
222237
});

src/bedrock-converse.ts

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,34 @@ export async function handleConverse(
600600
return;
601601

602602
if (!fixture) {
603+
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
604+
if (effectiveStrict) {
605+
const strictStatus = 503;
606+
const strictMessage = "Strict mode: no fixture matched";
607+
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
608+
journal.add({
609+
method: req.method ?? "POST",
610+
path: urlPath,
611+
headers: flattenHeaders(req.headers),
612+
body: completionReq,
613+
response: {
614+
status: strictStatus,
615+
fixture: null,
616+
...strictOverrideField(defaults.strict, req.headers),
617+
},
618+
});
619+
writeErrorResponse(
620+
res,
621+
strictStatus,
622+
JSON.stringify({
623+
error: {
624+
message: strictMessage,
625+
type: "invalid_request_error",
626+
},
627+
}),
628+
);
629+
return;
630+
}
603631
if (defaults.record) {
604632
const outcome = await proxyAndRecord(
605633
req,
@@ -623,31 +651,23 @@ export async function handleConverse(
623651
return;
624652
}
625653
}
626-
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
627-
const strictStatus = effectiveStrict ? 503 : 404;
628-
const strictMessage = effectiveStrict
629-
? "Strict mode: no fixture matched"
630-
: "No fixture matched";
631-
if (effectiveStrict) {
632-
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
633-
}
634654
journal.add({
635655
method: req.method ?? "POST",
636656
path: urlPath,
637657
headers: flattenHeaders(req.headers),
638658
body: completionReq,
639659
response: {
640-
status: strictStatus,
660+
status: 404,
641661
fixture: null,
642662
...strictOverrideField(defaults.strict, req.headers),
643663
},
644664
});
645665
writeErrorResponse(
646666
res,
647-
strictStatus,
667+
404,
648668
JSON.stringify({
649669
error: {
650-
message: strictMessage,
670+
message: "No fixture matched",
651671
type: "invalid_request_error",
652672
},
653673
}),
@@ -871,6 +891,34 @@ export async function handleConverseStream(
871891
return;
872892

873893
if (!fixture) {
894+
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
895+
if (effectiveStrict) {
896+
const strictStatus = 503;
897+
const strictMessage = "Strict mode: no fixture matched";
898+
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
899+
journal.add({
900+
method: req.method ?? "POST",
901+
path: urlPath,
902+
headers: flattenHeaders(req.headers),
903+
body: completionReq,
904+
response: {
905+
status: strictStatus,
906+
fixture: null,
907+
...strictOverrideField(defaults.strict, req.headers),
908+
},
909+
});
910+
writeErrorResponse(
911+
res,
912+
strictStatus,
913+
JSON.stringify({
914+
error: {
915+
message: strictMessage,
916+
type: "invalid_request_error",
917+
},
918+
}),
919+
);
920+
return;
921+
}
874922
if (defaults.record) {
875923
const outcome = await proxyAndRecord(
876924
req,
@@ -894,31 +942,23 @@ export async function handleConverseStream(
894942
return;
895943
}
896944
}
897-
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
898-
const strictStatus = effectiveStrict ? 503 : 404;
899-
const strictMessage = effectiveStrict
900-
? "Strict mode: no fixture matched"
901-
: "No fixture matched";
902-
if (effectiveStrict) {
903-
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
904-
}
905945
journal.add({
906946
method: req.method ?? "POST",
907947
path: urlPath,
908948
headers: flattenHeaders(req.headers),
909949
body: completionReq,
910950
response: {
911-
status: strictStatus,
951+
status: 404,
912952
fixture: null,
913953
...strictOverrideField(defaults.strict, req.headers),
914954
},
915955
});
916956
writeErrorResponse(
917957
res,
918-
strictStatus,
958+
404,
919959
JSON.stringify({
920960
error: {
921-
message: strictMessage,
961+
message: "No fixture matched",
922962
type: "invalid_request_error",
923963
},
924964
}),

src/bedrock.ts

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,34 @@ export async function handleBedrock(
428428
return;
429429

430430
if (!fixture) {
431+
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
432+
if (effectiveStrict) {
433+
const strictStatus = 503;
434+
const strictMessage = "Strict mode: no fixture matched";
435+
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
436+
journal.add({
437+
method: req.method ?? "POST",
438+
path: urlPath,
439+
headers: flattenHeaders(req.headers),
440+
body: completionReq,
441+
response: {
442+
status: strictStatus,
443+
fixture: null,
444+
...strictOverrideField(defaults.strict, req.headers),
445+
},
446+
});
447+
writeErrorResponse(
448+
res,
449+
strictStatus,
450+
JSON.stringify({
451+
error: {
452+
message: strictMessage,
453+
type: "invalid_request_error",
454+
},
455+
}),
456+
);
457+
return;
458+
}
431459
if (defaults.record) {
432460
const outcome = await proxyAndRecord(
433461
req,
@@ -451,31 +479,23 @@ export async function handleBedrock(
451479
return;
452480
}
453481
}
454-
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
455-
const strictStatus = effectiveStrict ? 503 : 404;
456-
const strictMessage = effectiveStrict
457-
? "Strict mode: no fixture matched"
458-
: "No fixture matched";
459-
if (effectiveStrict) {
460-
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
461-
}
462482
journal.add({
463483
method: req.method ?? "POST",
464484
path: urlPath,
465485
headers: flattenHeaders(req.headers),
466486
body: completionReq,
467487
response: {
468-
status: strictStatus,
488+
status: 404,
469489
fixture: null,
470490
...strictOverrideField(defaults.strict, req.headers),
471491
},
472492
});
473493
writeErrorResponse(
474494
res,
475-
strictStatus,
495+
404,
476496
JSON.stringify({
477497
error: {
478-
message: strictMessage,
498+
message: "No fixture matched",
479499
type: "invalid_request_error",
480500
},
481501
}),
@@ -1049,6 +1069,34 @@ export async function handleBedrockStream(
10491069
return;
10501070

10511071
if (!fixture) {
1072+
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
1073+
if (effectiveStrict) {
1074+
const strictStatus = 503;
1075+
const strictMessage = "Strict mode: no fixture matched";
1076+
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
1077+
journal.add({
1078+
method: req.method ?? "POST",
1079+
path: urlPath,
1080+
headers: flattenHeaders(req.headers),
1081+
body: completionReq,
1082+
response: {
1083+
status: strictStatus,
1084+
fixture: null,
1085+
...strictOverrideField(defaults.strict, req.headers),
1086+
},
1087+
});
1088+
writeErrorResponse(
1089+
res,
1090+
strictStatus,
1091+
JSON.stringify({
1092+
error: {
1093+
message: strictMessage,
1094+
type: "invalid_request_error",
1095+
},
1096+
}),
1097+
);
1098+
return;
1099+
}
10521100
if (defaults.record) {
10531101
const outcome = await proxyAndRecord(
10541102
req,
@@ -1072,31 +1120,23 @@ export async function handleBedrockStream(
10721120
return;
10731121
}
10741122
}
1075-
const effectiveStrict = resolveStrictMode(defaults.strict, req.headers);
1076-
const strictStatus = effectiveStrict ? 503 : 404;
1077-
const strictMessage = effectiveStrict
1078-
? "Strict mode: no fixture matched"
1079-
: "No fixture matched";
1080-
if (effectiveStrict) {
1081-
logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`);
1082-
}
10831123
journal.add({
10841124
method: req.method ?? "POST",
10851125
path: urlPath,
10861126
headers: flattenHeaders(req.headers),
10871127
body: completionReq,
10881128
response: {
1089-
status: strictStatus,
1129+
status: 404,
10901130
fixture: null,
10911131
...strictOverrideField(defaults.strict, req.headers),
10921132
},
10931133
});
10941134
writeErrorResponse(
10951135
res,
1096-
strictStatus,
1136+
404,
10971137
JSON.stringify({
10981138
error: {
1099-
message: strictMessage,
1139+
message: "No fixture matched",
11001140
type: "invalid_request_error",
11011141
},
11021142
}),

0 commit comments

Comments
 (0)