Skip to content

Commit 17e6107

Browse files
fix(project-engine-client): bypass Counterfact 406 on empty-body 2xx mock acks (#1744)
<!-- mysticat-pr-skill --> ## 1. Abstract Makes the Project Engine Counterfact mock return its intended empty-body 2xx ack (publish, batch-delete, update-benchmark) when the request carries `Accept: application/json`, instead of `406 Not Acceptable`. ## 2. Reasoning The mock 406'd on every empty-body 2xx handler whenever the caller sent `Accept: application/json` — which the serenity transport in spacecat-api-service does on every call. A bare `{ status, body }` return goes through Counterfact's response content-negotiation, and an empty-body `202` declares no `application/json` representation, so Counterfact answered `406` rather than the `202`. This is a mock-fidelity bug, not a production bug: the real Semrush gateway returns the empty ack regardless of `Accept`. The 406 blocked the serenity create/activate API-level IT increment — `createMarket` / `activate` create a draft then `publish`, and the publish step failed upstream with a `502 serenityUpstreamError`. ## 3. High-level overview of the changes A new `emptyAck()` helper returns a raw `{ status, body: '', contentType }` envelope. The explicit content type is the signal that makes Counterfact skip response negotiation/validation — the same bypass the mock's auth 401 and quota 405 already rely on. The helper is wired onto the per-request Context as `context.emptyAck`, mirroring how the auth guard is exposed. Behaviour delta: - Before: the four empty-body `202` ack handlers (publish, benchmarks batch-delete, benchmark update, brand-urls batch-delete) returned `406` under `Accept: application/json`; `*/*` or no `Accept` got `202`. - After: all four return `202` with an empty body regardless of `Accept`, matching the live gateway's `content-length: 0` ack. The three `204 No Content` handlers (project delete, ai_models batch-delete, prompts batch-delete) are intentionally left as bare `{ status: 204 }`: Counterfact special-cases `204` and serves it without negotiating, so they never 406. A `204` carries no body and no `Content-Type`, which the bare return preserves — keeping them closer to the live response than routing them through the helper would. ## 4. Required information - Jira / issue: #1742 - Other: blocked IT increment adobe/spacecat-api-service#2709 · PE mock image #1732 ## 5. Affected / used mysticat-workspace projects - spacecat-api-service — consumer (contract). Its serenity API-level IT suite boots this mock as a GHCR image; the create/activate lifecycle increment is unblocked once the mock image carrying this fix is published and the tag is bumped there. ## 6. Additional information outside the code Booted the mock locally (`MOCK_SEED=workspace-with-data`) and replayed the issue's reproduction against the seeded workspace/project under `Accept: application/json`: - publish, benchmarks batch-delete, benchmark PUT, brand-urls batch-delete — each now responds `202` (was `406`); the publish response body is `0` bytes, matching the live `content-length: 0` ack. - The three `204` delete handlers still respond `204` under the same `Accept: application/json`, confirming they were correctly left untouched. Validated against the real prod gateway with an IMS prod token (workspace `bfe3c7eb-469f-4331-9b10-7b1319bb6460`), probing the empty-2xx ack path non-destructively via a no-op batch delete (`{"ids": []}`): - Prod returns `202` with `content-length: 0` and **no `Content-Type` header**. - The mock matches prod on status and (empty) body, but adds one `Content-Type: application/json` header prod does not send. This is a known, unavoidable Counterfact limitation — a truthy content type is the only way to get a 0-byte body past Counterfact's negotiation; without it the response either `406`s or falls back to a `text/plain` "Accepted" 8-byte body (verified across both the raw-return and typed `$.response[202]` paths). The extra header is invisible to the consumer, which keys off `response.ok`/status. Documented inline in `mock/responses.js`. ## 7. Test plan - Local: booted the mock and ran the issue's repro `curl` for all four ack endpoints with `Accept: application/json` (results in section 6); also ran the package e2e suite, which drives the real typed client against a live mock and now sends `Accept: application/json` on the raw-fetch ack assertions to pin the regression. - To verify in the consuming repo: build/publish the mock image from this branch (or the released tag), bump the mock image tag in adobe/spacecat-api-service#2709 , and run the serenity create/activate IT lifecycle — `POST .../markets` (createMarket) and `POST .../activate` should reach `201` / `200` instead of `502`. ## 8. Deployment & merge order - Related: adobe/spacecat-api-service#2709 (related) — consumes the fixed mock image; its create/activate increment depends on this. - Related: #1732 (related) — the release-tag workflow that publishes the mock GHCR image. Order: merge this PR → semantic-release publishes a new package version and the mock-image workflow publishes a new GHCR tag → bump that tag in the api-service PR to unblock its IT. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f43104 commit 17e6107

9 files changed

Lines changed: 146 additions & 16 deletions

File tree

packages/spacecat-shared-project-engine-client/CLAUDE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,17 @@ entity through them — `context.factories.createBenchmarkMock({ brand_name, dom
7070
catalog handlers map their data rows through `createLanguageMock` / `createAiModelMock` /
7171
`createBrandTopicMock` — rather than emitting an inline literal that the untyped handler (the
7272
`@ts-check` exception) couldn't catch drifting. The factory is the single, tsc-checked source of
73-
truth for each shape. This holds for EVERY handler, including the trivial envelope shapes: the
74-
202 acks build a `createBasicResponseMock`, `getInitStatus` a `createInitStatusMock`, and
75-
`updateCiCompetitors` maps through `createCiCompetitorMock` — there are no inline-literal
76-
exceptions left.
73+
truth for each shape. This holds for EVERY handler that returns an entity, including the trivial
74+
envelope shapes: `getInitStatus` builds a `createInitStatusMock` and `updateCiCompetitors` maps
75+
through `createCiCompetitorMock` — there are no inline-literal entity exceptions left.
76+
77+
The empty-body action acks (publish, batch-delete, update-benchmark) are NOT entities — they
78+
mirror the live gateway's `content-length: 0` ack, so they return `context.emptyAck(202)` from
79+
`mock/responses.js` rather than a factory. `emptyAck` is a raw `{ status, body: '', contentType }`
80+
envelope (like `auth.js`'s 401 and `quota.js`'s 405): the explicit `contentType` makes Counterfact
81+
skip response content-negotiation, which otherwise **406**s an empty-body 2xx under `Accept:
82+
application/json` (the header the real serenity transport always sends). A bare `{ status: 204 }`
83+
delete needs no helper — Counterfact serves 204 No Content without negotiating.
7784

7885
## Spec corrections: the overlay is the single source of truth
7986

packages/spacecat-shared-project-engine-client/mock/context.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { InMemoryStore } from './store.js';
4141
import { createStatefulOps } from './stateful.js';
4242
import { createQuota } from './quota.js';
4343
import { authError } from './auth.js';
44+
import { emptyAck } from './responses.js';
4445
import * as factories from './factories.js';
4546
import { SEEDS, DEFAULT_SEED } from './seeds.js';
4647

@@ -70,6 +71,12 @@ export class Context {
7071
// Bearer-auth gate. Stateless, so it is a plain reference to the pure guard; every real route
7172
// calls `context.authError($.headers)`, the `__*` control routes do not (see mock/auth.js).
7273
this.authError = authError;
74+
// Empty-body 2xx ack shape (mock/responses.js). Stateless, so a plain reference to the pure
75+
// helper. Action-ack handlers (publish, batch-delete, update-benchmark) return
76+
// `context.emptyAck(202)` so the empty body carries an explicit content type and bypasses
77+
// Counterfact's response negotiation, which otherwise 406s under `Accept: application/json`.
78+
// 204 No Content handlers don't need it — Counterfact serves 204 raw (see mock/responses.js).
79+
this.emptyAck = emptyAck;
7380
// The typed entity factories (mock/factories.js), exposed so route handlers build every
7481
// response entity through them (`context.factories.createXMock(...)`) instead of inline
7582
// literals — the factory is the single, tsc-checked source of truth for each shape, so the

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function DELETE($) {
4848
{ workspaceId: path.id, projectId: path.project_id },
4949
body?.ids ?? [],
5050
);
51-
// Empty body (content-length 0) like live, not Counterfact's default "Accepted" reason.
52-
return { status: 202, body: '' };
51+
// Empty body (content-length 0) like live. The explicit content type (via emptyAck) bypasses
52+
// Counterfact's response negotiation, which would otherwise 406 under `Accept: application/json`.
53+
return context.emptyAck(202);
5354
}

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/ai_models/benchmarks/{benchmark_id}.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function PUT($) {
2727
path.benchmark_id,
2828
{ ...body },
2929
);
30-
// Empty body (content-length 0) like live, not Counterfact's default "Accepted" reason.
31-
return { status: 202, body: '' };
30+
// Empty body (content-length 0) like live. The explicit content type (via emptyAck) bypasses
31+
// Counterfact's response negotiation, which would otherwise 406 under `Accept: application/json`.
32+
return context.emptyAck(202);
3233
}

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/publish.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function POST($) {
3030
contentType: 'application/json',
3131
};
3232
}
33-
// Empty body (content-length 0) like live, not Counterfact's default "Accepted" reason.
34-
return { status: 202, body: '' };
33+
// Empty body (content-length 0) like live. The explicit content type (via emptyAck) bypasses
34+
// Counterfact's response negotiation, which would otherwise 406 under `Accept: application/json`.
35+
return context.emptyAck(202);
3536
}

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/aio/benchmarks/{benchmark_id}/brand_urls.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function DELETE($) {
5353
{ workspaceId: path.id, projectId: path.project_id, benchmarkId: path.benchmark_id },
5454
body?.ids ?? [],
5555
);
56-
// Empty body (content-length 0) like live, not Counterfact's default "Accepted" reason.
57-
return { status: 202, body: '' };
56+
// Empty body (content-length 0) like live. The explicit content type (via emptyAck) bypasses
57+
// Counterfact's response negotiation, which would otherwise 406 under `Accept: application/json`.
58+
return context.emptyAck(202);
5859
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// @ts-check
14+
15+
/**
16+
* Shared raw-response shapes for the Project Engine mock.
17+
*
18+
* Counterfact runs a bare `{ status, body }` return through response content-negotiation: it
19+
* picks a representation for the request's `Accept` header. An empty-body 2xx (the live "action
20+
* ack" — publish / batch-delete / update-benchmark, all `content-length: 0` live) declares no
21+
* `application/json` representation, so when the caller sends `Accept: application/json` — which
22+
* the real serenity transport in `spacecat-api-service` does on EVERY call — Counterfact has
23+
* nothing to serve and answers **406 Not Acceptable** instead of the intended 2xx. The real
24+
* Semrush gateway never does this: a no-content response is returned regardless of `Accept`.
25+
*
26+
* The fix is the same raw-return bypass `auth.js` (the 401) and `quota.js` (the disguised 405)
27+
* already rely on: a `{ status, body, contentType }` literal carries an explicit content type, so
28+
* Counterfact skips negotiation/validation and serves the response verbatim. {@link emptyAck}
29+
* centralizes that shape so empty-body ack handlers can't reintroduce the negotiation 406.
30+
*
31+
* KNOWN, UNAVOIDABLE DIVERGENCE — the mock's empty 202 carries a `Content-Type: application/json`
32+
* header; live prod sends the empty ack with `content-length: 0` and NO `Content-Type` at all
33+
* (captured 2026-06-29 against the real gateway: `202`, `content-length: 0`, no content type). This
34+
* is a Counterfact framework limit, not a choice: a truthy `contentType` is the ONLY way to get a
35+
* 0-byte body past negotiation — without it the body either 406s or falls back to Counterfact's
36+
* default `text/plain` "Accepted" 8-byte body (both verified across the raw-return and typed
37+
* `$.response[202]` paths). The divergence is invisible to the only consumer — the serenity
38+
* transport keys off `response.ok`/status, and the body is empty either way — so status + body
39+
* match prod exactly; only this one extra response header differs. `application/json` is chosen
40+
* because it matches the caller's `Accept`.
41+
*
42+
* NOTE — this is for `2xx` acks that carry an EMPTY body but are NOT `204`. A bare
43+
* `{ status: 204 }` is already safe: Counterfact special-cases `204 No Content` and serves it
44+
* without negotiating (verified 2026-06-29 — `204` returns `204` under `Accept: application/json`,
45+
* while `202, body: ''` 406s), so the `204` delete handlers deliberately do NOT go through here —
46+
* a 204 carries no body and (per HTTP) no `Content-Type`, which the bare return preserves.
47+
*/
48+
49+
/**
50+
* An empty-body 2xx ack shaped to bypass Counterfact's response content-negotiation, mirroring
51+
* the live gateway's `content-length: 0` action acks. The `contentType` is what makes Counterfact
52+
* skip negotiation; the body stays empty and the consumer keys off `response.ok`/status only, so
53+
* `application/json` (matching the caller's `Accept`) is the natural, negotiation-satisfying
54+
* choice. Use for empty-body `202`-class acks; `204 No Content` needs no helper (see module note).
55+
* @param {200 | 201 | 202} [status] the empty-body 2xx ack status (defaults to the publish/
56+
* action-ack `202`); the literal union keeps the "empty-body 2xx ack only" contract tsc-enforced
57+
* @returns {{ status: 200 | 201 | 202, body: string, contentType: string }}
58+
*/
59+
export function emptyAck(status = 202) {
60+
return { status, body: '', contentType: 'application/json' };
61+
}

packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -585,11 +585,17 @@ async function waitForReady(baseUrl, deadline, getStderr) {
585585

586586
// Live ack: 202 with an EMPTY body (verified 2026-06-25), not a BasicResponse. Raw fetch so the
587587
// empty body is asserted — the typed client swallows it, so a regression to a JSON body passes.
588+
// `Accept: application/json` (what the real serenity transport sends) pins the negotiation-
589+
// bypass fix: without the handler's content type this empty 202 would 406 (issue 1742).
588590
const benchUrl = `${baseUrl}/v1/workspaces/${SEED_WORKSPACE}`
589591
+ `/projects/${SEED_PROJECT}/ai_models/benchmarks`;
590592
const rawBenchDel = await fetch(benchUrl, {
591593
method: 'DELETE',
592-
headers: { Authorization: 'Bearer e2e-token', 'content-type': 'application/json' },
594+
headers: {
595+
Authorization: 'Bearer e2e-token',
596+
'content-type': 'application/json',
597+
Accept: 'application/json',
598+
},
593599
body: JSON.stringify({ ids: created.ids }),
594600
});
595601
expect(rawBenchDel.status).to.equal(202);
@@ -604,11 +610,16 @@ async function waitForReady(baseUrl, deadline, getStderr) {
604610
// Mirrors the consumer's updateBenchmark: PUT a brand_aliases re-sync, list reflects it.
605611
it('updates a benchmark in place (PUT v1) and the list reflects the change', async () => {
606612
// Live ack: 202 with an EMPTY body (verified 2026-06-25) — raw fetch asserts the empty body.
613+
// `Accept: application/json` pins the negotiation-bypass fix (406 otherwise — issue 1742).
607614
const benchPutUrl = `${baseUrl}/v1/workspaces/${SEED_WORKSPACE}`
608615
+ `/projects/${SEED_PROJECT}/ai_models/benchmarks/${SEED_IDS.benchmarkId}`;
609616
const rawBenchPut = await fetch(benchPutUrl, {
610617
method: 'PUT',
611-
headers: { Authorization: 'Bearer e2e-token', 'content-type': 'application/json' },
618+
headers: {
619+
Authorization: 'Bearer e2e-token',
620+
'content-type': 'application/json',
621+
Accept: 'application/json',
622+
},
612623
body: JSON.stringify({ brand_aliases: ['Adobe Inc', 'Adobe Systems'] }),
613624
});
614625
expect(rawBenchPut.status).to.equal(202);
@@ -656,11 +667,16 @@ async function waitForReady(baseUrl, deadline, getStderr) {
656667
expect(listed.brand_urls.map((u) => u.id)).to.include(created.ids[0]);
657668

658669
// Live ack: 202 with an EMPTY body (verified 2026-06-25) — raw fetch asserts the empty body.
670+
// `Accept: application/json` pins the negotiation-bypass fix (406 otherwise — issue 1742).
659671
const buUrl = `${baseUrl}/v2/workspaces/${SEED_WORKSPACE}`
660672
+ `/projects/${SEED_PROJECT}/aio/benchmarks/${benchmarkId}/brand_urls`;
661673
const rawBuDel = await fetch(buUrl, {
662674
method: 'DELETE',
663-
headers: { Authorization: 'Bearer e2e-token', 'content-type': 'application/json' },
675+
headers: {
676+
Authorization: 'Bearer e2e-token',
677+
'content-type': 'application/json',
678+
Accept: 'application/json',
679+
},
664680
body: JSON.stringify({ ids: created.ids }),
665681
});
666682
expect(rawBuDel.status).to.equal(202);
@@ -677,9 +693,12 @@ async function waitForReady(baseUrl, deadline, getStderr) {
677693
expect(pubRes.status).to.equal(202);
678694
// Live action acks (publish, delete/update-benchmark, delete-brand-urls) return a 202 with an
679695
// EMPTY body (verified 2026-06-25), not a BasicResponse — a raw fetch confirms no body.
696+
// `Accept: application/json` (the serenity transport's header) pins the negotiation-bypass fix:
697+
// this is the exact request shape that 406'd and blocked the create/activate IT (issue 1742).
680698
const pubUrl = `${baseUrl}/v1/workspaces/${SEED_WORKSPACE}/projects/${SEED_PROJECT}/publish`;
681699
const rawPub = await fetch(pubUrl, {
682-
method: 'POST', headers: { Authorization: 'Bearer e2e-token' },
700+
method: 'POST',
701+
headers: { Authorization: 'Bearer e2e-token', Accept: 'application/json' },
683702
});
684703
expect(rawPub.status).to.equal(202);
685704
expect(await rawPub.text()).to.equal('');
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { expect } from 'chai';
14+
import { emptyAck } from '../../mock/responses.js';
15+
16+
describe('mock responses', () => {
17+
describe('emptyAck', () => {
18+
it('returns the raw empty-202 ack with an explicit content type (the negotiation bypass)', () => {
19+
expect(emptyAck()).to.deep.equal({
20+
status: 202,
21+
body: '',
22+
contentType: 'application/json',
23+
});
24+
});
25+
26+
it('acks with the given 2xx status (and still bypasses negotiation)', () => {
27+
// The non-default arg exercises the other branch of the `status = 202` default, so the
28+
// no-arg test above plus this one cover both; an empty body + contentType stay invariant.
29+
expect(emptyAck(200)).to.deep.equal({ status: 200, body: '', contentType: 'application/json' });
30+
});
31+
});
32+
});

0 commit comments

Comments
 (0)