MSW and Vitest: mock a Sume 409 idempotency_conflict

Use MSW's http.post and setupServer in Vitest to test how your code handles a Sume Format run 409, with onUnhandledRequest set to error.

5 min readSume
All posts

Short answer

Register the handler with http.post and HttpResponse.json(body, { status: 409 }), run it through setupServer from msw/node, and call server.listen with onUnhandledRequest set to error. Your Vitest suite then exercises the real fetch path against a pretend Sume without a network call or a charge. The setup calls follow the MSW quick start; the onUnhandledRequest option is an MSW setting the quick start does not show, so check it in MSW's API reference.

The MSW pieces

MSW intercepts at the request layer, so your code under test stays unchanged. The pieces below are the ones the quick start shows, plus the onUnhandledRequest option.

MSW calls used in this test, per its quick start (read 2026-10-03)
CallRole
http.post(url, resolver)Declares a handler for a POST
HttpResponse.json(body, { status })Builds the mocked reply
setupServer from msw/nodeCreates the Node server
server.listen({ onUnhandledRequest: 'error' })Fails the test on any unmocked request
server.resetHandlers()Drops per-test overrides after each test
server.close()Stops interception at the end

Why unhandled requests should error

With onUnhandledRequest set to error, a typo in a path fails the test instead of silently reaching api.sume.com with your key. That matters here: a real POST to a generation route spends credits.

The status you mock should match the real contract. On a Format run, the same Idempotency-Key with a different body is 409 idempotency_conflict, and a concurrent duplicate is 409 idempotency_key_in_use. Sume error bodies have the shape error.code, error.message, error.request_id and error.details, so mock that shape.

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";

const url = "https://api.sume.com/v1/formats/acme/hero/runs";
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

async function submit(key: string, body: object) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Idempotency-Key": key, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error.code);
  return json;
}

test("same key with a different body is refused", async () => {
  server.use(
    http.post(url, () =>
      HttpResponse.json({ error: { code: "idempotency_conflict" } }, { status: 409 }),
    ),
  );
  await expect(submit("k1", { a: 1 })).rejects.toThrow("idempotency_conflict");
});

Per-test overrides

Keep the default handlers for the happy path in setupServer and use server.use in a test for the failure. resetHandlers then restores the defaults, so tests do not leak into each other.

Add a second case for 202 followed by a status poll to cover your loop; the pattern is in Vitest submit and poll with fake status replies. The error codes and statuses are listed in the Sume errors docs.

Cases worth covering

A small set of handlers gives you most of the value. Each one maps to a branch in your client code, so a missing test is a branch you have never run.

  • 202 with a job envelope, then a status reply with terminal false, then terminal true.
  • 200 with idempotency_hit true on a Format run replay, to confirm you do not create a second record.
  • 409 idempotency_conflict, which your code should surface as a bug, not retry.
  • 409 idempotency_key_in_use, which your code may retry after a short wait.
  • 429 with a retry-after header, to check the delay is honored.
  • 401 unauthorized, to check you never retry a credential error.

Keeping mocks honest

A mock only helps while it matches the real service. Pull status codes and error codes from the documentation rather than from memory, and re-check them when you upgrade your SDK. For a stricter loop, compare your mock bodies with the live OpenAPI document that the Sume API publishes, so a renamed field breaks a test before it breaks production.

Finally, keep these tests separate from any live smoke test. The mocked suite should run on every commit with no key in the environment; a live check, if you have one, should hit a read-only route.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume