DEV Community

Riley Zhu
Riley Zhu

Posted on

The Agent That Folded a Circuit Breaker Into a Sleep Loop: A Take-Home Packet

Agent-written retry helpers often collapse circuit breaking, backoff, and caching into a single loop that hides failure. A take-home review packet exposes that collapse more reliably than a generic prompt to find bugs. This packet asks candidates to review a small TypeScript client, score it against a rubric, and propose a bounded fix. The exercise stays useful even when the reviewer is a model running on a free endpoint.

The failure this packet isolates

Circuit-breaker tutorials usually show three states, a failure threshold, and a timed half-open probe before traffic resumes. Agent-generated HTTP clients frequently skip those states and emit a while loop that only sleeps between calls. That loop retries 429, 500, and timeout together, then returns a cached body so the caller never sees an error. Interviewers who only ask for a verbal definition of backoff routinely miss this exact control-flow collapse.

The packet is intentionally small so a reviewer can finish it in one sitting without production credentials. Candidates receive a single module, four failing tests, and a written prompt that forbids expanding the surface area. The intended lesson is not clever exponential math or jitter folklore from blog posts. The intended lesson is that retry, cache, and breaker policy must remain separate objects with explicit budgets.

Loop-engineering failures of this kind keep showing up in agent-authored clients because a sleep statement looks like progress in a diff. A green local run then hides the fact that callers receive a synthesized done job after every timeout. The take-home below makes that invented success path visible with offline tests rather than a live staging cluster.

Prompt to give candidates

Copy the following block into the take-home instructions without extra framing from the interviewer. The prompt withholds the rubric on purpose so candidates cannot optimize for a checklist they have already seen.

You are reviewing a TypeScript HTTP helper that an agent generated
for an internal job-status API.

Files:
- src/jobStatusClient.ts
- src/jobStatusClient.test.ts

Requirements:
1. Do not add new dependencies.
2. Do not talk to a live network.
3. Identify every way the client can report success after a failed fetch.
4. Patch the client so a circuit opens after three consecutive failures,
   stays open for 2_000 ms of fake timers, then allows one half-open probe.
5. Timeouts, 429, and 5xx must not be written into the success cache.
6. Return a structured error after the deadline; never a stale JSON body.
7. Keep the public function signature `getJob(id: string): Promise<Job>`.

Submit:
- a short review note (max 25 lines)
- a patch
- `npx tsc --noEmit` and `npx vitest run` output
Enter fullscreen mode Exit fullscreen mode

Interviewers should score the submission privately against the table in the rubric section. Models used as first-pass reviewers should receive the same prompt, not a longer lecture about distributed systems. Extra context about outages at previous employers is not part of this packet and should stay out of the instructions.

The agent-written artifact

The following module is the fixture. Interviewers should paste it as the starting point and keep comments intact, because several comments are incorrect on purpose. This code is a labeled take-home fixture, not a snippet from a shipping service.

// src/jobStatusClient.ts
export type Job = { id: string; state: "queued" | "running" | "done" };

type CacheEntry = { body: Job; storedAt: number };

const cache = new Map<string, CacheEntry>();
let sleepMs = 50;

async function sleep(ms: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, ms));
}

function looksComplete(text: string): Job | null {
  try {
    const parsed = JSON.parse(text) as Partial<Job>;
    if (parsed && parsed.id) {
      return {
        id: String(parsed.id),
        state: (parsed.state as Job["state"]) ?? "done",
      };
    }
  } catch {
    // Agent comment: partial JSON still means the job is probably done.
  }
  return null;
}

export async function getJob(id: string): Promise<Job> {
  const deadline = Date.now() + 8_000;
  let lastText = "";

  while (Date.now() < deadline) {
    try {
      const res = await fetch(`/jobs/${id}`, {
        signal: AbortSignal.timeout(400),
      });
      lastText = await res.text();

      if (res.status === 429 || res.status >= 500) {
        await sleep(sleepMs);
        sleepMs = Math.min(sleepMs * 2, 2_000);
        continue;
      }

      const job = looksComplete(lastText) ?? cache.get(id)?.body;
      if (job) {
        cache.set(id, { body: job, storedAt: Date.now() });
        sleepMs = 50;
        return job;
      }
    } catch {
      const cached = cache.get(id);
      if (cached) {
        return cached.body;
      }
      await sleep(sleepMs);
    }
  }

  const guessed = looksComplete(lastText);
  if (guessed) {
    return guessed;
  }
  return { id, state: "done" };
}
Enter fullscreen mode Exit fullscreen mode

A companion test file should fail before the patch and pass after it. The tests below use fake timers so the packet stays offline and does not wait on a real eight-second deadline during review.

// src/jobStatusClient.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { getJob } from "./jobStatusClient";

describe("getJob", () => {
  afterEach(() => {
    vi.unstubAllGlobals();
    vi.useRealTimers();
  });

  it("does not treat 429 as a completed job", async () => {
    vi.useFakeTimers();
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        status: 429,
        text: async () => "",
      }),
    );
    const pending = getJob("job-1");
    const assertion = expect(pending).rejects.toMatchObject({
      code: "circuit_open",
    });
    await vi.runAllTimersAsync();
    await assertion;
  });

  it("does not return stale cache after a timeout", async () => {
    vi.useFakeTimers();
    vi.stubGlobal(
      "fetch",
      vi.fn().mockRejectedValue(new DOMException("TimeoutError")),
    );
    const pending = getJob("job-2");
    const assertion = expect(pending).rejects.toMatchObject({
      code: "deadline_exceeded",
    });
    await vi.runAllTimersAsync();
    await assertion;
  });

  it("opens after three failures and allows one half-open probe", async () => {
    vi.useFakeTimers();
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce({ status: 500, text: async () => "" })
      .mockResolvedValueOnce({ status: 500, text: async () => "" })
      .mockResolvedValueOnce({ status: 500, text: async () => "" })
      .mockResolvedValueOnce({
        status: 200,
        text: async () => JSON.stringify({ id: "job-3", state: "running" }),
      });
    vi.stubGlobal("fetch", fetchMock);

    const pending = getJob("job-3");
    await vi.advanceTimersByTimeAsync(2_000);
    await expect(pending).resolves.toEqual({ id: "job-3", state: "running" });
    expect(fetchMock).toHaveBeenCalledTimes(4);
  });

  it("never defaults a missing state field to done", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        status: 200,
        text: async () => JSON.stringify({ id: "job-4" }),
      }),
    );
    await expect(getJob("job-4")).rejects.toMatchObject({
      code: "malformed_body",
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Hidden success paths in the fixture are worth listing before any candidate starts the clock.

  • A 429 response never throws; the loop sleeps, then later invents { state: "done" } from an empty body or a leftover identifier.
  • A thrown timeout returns whatever the process-wide Map still holds, including a job from a previous identifier collision if tests leak state.
  • Partial JSON without a state field is promoted to done, which turns a truncated stream into a terminal success for the caller.
  • After the deadline, the function still returns a guessed object instead of a structured error the caller can branch on.

Commands the candidate should run

Interviewers can require these exact commands so logs stay comparable across submissions. The expected first run is four failing tests and a typecheck that only becomes interesting after implicit casts disappear.

npm init -y
npm install --save-dev typescript vitest @types/node
npx tsc --init --target ES2022 --module nodenext --strict
npx tsc --noEmit
npx vitest run
Enter fullscreen mode Exit fullscreen mode

Candidates who fix the suite by deleting assertions should score zero on the honesty row of the rubric. Candidates who add a live fetch to a public API should also score zero, because the packet is designed to stay offline. A submission that needs wall-clock sleeps longer than fake timers provide is not demonstrating breaker behavior; it is demonstrating patience in CI.

Rubric

Score each row from 0 to 2. A hiring bar of 10 out of 12 is strict enough for a mid-level backend or platform role. Interviewers should keep this rubric off the candidate prompt so people inspect control flow instead of matching headings.

  1. Hidden success paths (0-2). The review names cache fallback, default state: "done", and partial JSON promotion as success paths that survive failed fetches.
  2. State machine (0-2). The patch introduces closed, open, and half-open states rather than a longer sleep inside the original while loop.
  3. Error taxonomy (0-2). Timeouts, 429, 5xx, and malformed bodies become distinct error codes instead of one silent catch block.
  4. Budget (0-2). A deadline and a probe interval exist, and the loop cannot spin unbounded when Date.now is replaced by fake timers.
  5. Tests left intact (0-2). Existing tests remain in place; new tests may be added, but none may be deleted to obtain a green run.
  6. Scope control (0-2). No new dependencies, no extra exported functions, and no production feature flags appear in the candidate patch.

A verbal essay about exponential backoff without a patch should not pass the state-machine row. A patch that only lowers sleepMs also fails that row, even when the author writes a long note about jitter. The honesty row exists because agent-assisted candidates sometimes delete the malformed-body test and call the suite complete.

Sample solution (labeled proposal)

The following patch is a proposed interviewer key, not production library code. It keeps one module and replaces the sleep loop with an explicit breaker, while still polling because a job-status API remains a poll.

type BreakerState = "closed" | "open" | "half_open";

class JobStatusError extends Error {
  constructor(public code: string, message: string) {
    super(message);
  }
}

const FAILURE_LIMIT = 3;
const OPEN_MS = 2_000;
const DEADLINE_MS = 8_000;

let breaker: BreakerState = "closed";
let failures = 0;
let openedAt = 0;

function recordFailure(): void {
  failures += 1;
  if (failures >= FAILURE_LIMIT) {
    breaker = "open";
    openedAt = Date.now();
  }
}

function recordSuccess(): void {
  failures = 0;
  breaker = "closed";
}

function allowProbe(now: number): boolean {
  if (breaker === "closed") return true;
  if (breaker === "open" && now - openedAt >= OPEN_MS) {
    breaker = "half_open";
    return true;
  }
  return breaker === "half_open";
}

export async function getJob(id: string): Promise<Job> {
  const deadline = Date.now() + DEADLINE_MS;
  let lastError = new JobStatusError("unknown", "no attempt");

  while (Date.now() < deadline) {
    const now = Date.now();
    if (!allowProbe(now)) {
      lastError = new JobStatusError("circuit_open", "breaker open");
      await new Promise((resolve) => setTimeout(resolve, 50));
      continue;
    }

    try {
      const res = await fetch(`/jobs/${id}`, {
        signal: AbortSignal.timeout(400),
      });
      if (res.status === 429 || res.status >= 500) {
        recordFailure();
        lastError = new JobStatusError("upstream", `status ${res.status}`);
        continue;
      }
      const parsed = JSON.parse(await res.text()) as Partial<Job>;
      if (
        !parsed.id ||
        (parsed.state !== "queued" &&
          parsed.state !== "running" &&
          parsed.state !== "done")
      ) {
        recordFailure();
        throw new JobStatusError("malformed_body", "missing id or state");
      }
      recordSuccess();
      return { id: String(parsed.id), state: parsed.state };
    } catch (err) {
      if (err instanceof JobStatusError && err.code === "malformed_body") {
        throw err;
      }
      recordFailure();
      lastError = new JobStatusError("deadline_exceeded", "fetch failed");
    }
  }

  throw lastError.code === "circuit_open"
    ? lastError
    : new JobStatusError("deadline_exceeded", lastError.message);
}
Enter fullscreen mode Exit fullscreen mode

Reviewers should treat this sample as a ceiling for the take-home, not as a library to copy into a service mesh. Process-wide breaker variables are acceptable inside one fixture file and would be the wrong isolation model in a multi-tenant process. The sample still uses a loop on purpose, so candidates cannot claim that removing while was the entire design.

Common failure modes

Reviewers, human or model, repeat a small set of mistakes on this fixture. Each failure mode should cost points even when the test file is green after destructive edits.

  • Deleting the cache and calling the work done still leaves the 429 path spinning until the deadline without three named breaker states.
  • Mapping every throw to deadline_exceeded hides malformed_body and makes the fourth test impossible to satisfy without weakening assertions.
  • Adding Math.random jitter to the sleep width looks sophisticated and still returns { state: "done" } on empty response bodies.
  • Using real timers in CI creates an eight-second wait that makes the suite flaky and teaches nothing about half-open probes.
  • Opening the breaker and then allowing a burst of fetches is not a probe; a half-open state should admit one in-flight request only.
  • Treating 429 as success after several tries is a common agent heal that returns the last parsed id with a default terminal state.

A green suite that deleted the malformed-body test is a stronger reject signal than a red suite with a precise review note. Interviewers should read the review note first, then the diff, then the test log, because that order catches narrative that does not match the patch.

Optional first-pass review on a free endpoint

Teams that already run take-home packets through a model can keep this fixture local and offline for tests, then send only the two source files to a reviewer model. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project that currently offers free model access, including an operator-stated 10 million token allotment, and a free server option that can host that review pass without a paid GPU.

A practical sequence stays inside the same repository as the packet and refuses to upload secrets.

# labeled example: run tests first so the model cannot fix a red suite by guessing
npx vitest run src/jobStatusClient.test.ts

# labeled example: send only the fixture files, not cookies or live job ids
# Use the local CLI already chosen for MonkeyCode free model access.
# Keep production tokens and staging hostnames out of the prompt body.
Enter fullscreen mode Exit fullscreen mode

The model output is a draft review, not a hiring decision. Interviewers should still apply the rubric by hand, because models often praise a sleep increase as if it were a circuit breaker. The free server option is relevant only as a disposable sandbox for that draft, and it does not replace the offline unit tests in the packet.

Limitations and who should skip this packet

This packet does not teach queue theory, hedging, or multi-region failover, and it should not be stretched into those topics during scoring. The half-open interval is a fake two seconds so tests remain fast, which is not an operations recommendation for production traffic. The sample error type is a local class rather than a shared problem catalog, so companies with existing error taxonomies should adapt names before reuse. The fixture also assumes fetch and AbortSignal.timeout, which older runtimes will not provide without a polyfill that this prompt forbids.

People who should skip this exercise include candidates being hired for visual design only, and teams that cannot accept a TypeScript take-home at all. It is also the wrong packet for evaluating prompt-injection defense, data-retention policy, or vendor lock-in around any particular model host. Interviewers who need those signals should write a different fixture instead of bolting extra story onto this loop.

The packet is complete when a candidate can name every invented-success path and replace the sleep loop with three named breaker states. That bar is enough for a take-home. Extra essays about token prices and model vendors do not add signal for this role.

Top comments (0)