Skip to content

    Forward Deployed Engineer

    Failure Injection Tests for a Tool-Calling Workflow

    Write pytest failure-injection tests for a tool-calling workflow with fake tools and a fake clock: tool errors, bad arguments, duplicates, timeouts, retries.

    By Published 7 min read

    Share

    This lesson is part of Forward Deployed Engineer: Agents, Tools and Guardrails. Enroll for free, then sign in, to read it in full.

    What you'll learn: how to test a bounded workflow by injecting failures with deterministic fakes, which five failure classes to cover, what the capstone runner does for each, and how to bound retries so they cannot break the time limit.

    At a glance

    Figure 1. The test harness: everything the runner talks to is a fake

    A failure-injection test harness around the workflow runnerTest to Orchestrator (runs); Scripted planner to Orchestrator (plans); Orchestrator to Fake tool (calls); Fake clock to Orchestrator (time); Orchestrator to Step log (records)Test: pytest, no networkTestpytest, nonetworkScripted planner: fake modelScriptedplannerfake modelOrchestrator: WorkflowRunnerOrchestratorWorkflowRunnerFake tool: scripted failuresFake toolscripted failuresFake clock: moves on demandFake clockmoves on demandStep log: asserted onStep logasserted onrunsplanscallstimerecords

    How to read this: the fakes decide what goes wrong and when; the test checks the status, the step log and the call counts.

    • Tests with a real model or tool are slow, cost money and fail for outside reasons.
    • Five failure classes cover most of the loop: tool errors, malformed arguments, duplicate calls, timeouts and a misbehaving planner.
    • The runner turns tool failures into observations. It never retries a call itself.
    • Retry inside the tool, with a fixed number of attempts, and keep the total under the time limit.

    Scenario

    Composite scenario, not a real company. At Lumen Industrial Supply, a fictional distributor, the product-discovery assistant passes a demo. In the pilot, the catalog service times out twice in an afternoon, the model returns half a JSON reply, and one request repeats a lookup. Each failure is rare, so manual testing never meets it.

    The team wants a millisecond test for every failure class before the pilot widens. Numbers are illustrative.

    What you will be able to do

    • Drive WorkflowRunner with a fake tool, a fake clock and a scripted planner.
    • Write tests for tool failure, malformed arguments, duplicate calls, timeouts and an unreadable plan.
    • Say what each failure leaves in the step log.
    • Put retries inside a tool and show they are bounded.

    Prerequisites

    Core ideas

    Inject, run, assert

    A fake tool plays a script of outcomes, each a result or an exception to raise.

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L6/m4_l6_fakes.py

    @dataclass
    class FakeTool:
        """Plays `outcomes` in order and repeats the last one. An Exception instance is raised, anything else is returned."""
    
        outcomes: list[Any]
        clock: FakeClock | None = None
        cost_s: float = 0.0
        calls: int = 0
    
        def __call__(self, args: LookupArgs, ctx: ToolContext) -> dict[str, Any]:
            self.calls += 1
            if self.clock is not None:
                self.clock.advance(self.cost_s)
            outcome = self.outcomes[min(self.calls, len(self.outcomes)) - 1]
            if isinstance(outcome, Exception):
                raise outcome
            return outcome

    The calls counter is the most useful assertion. The fake clock moves only when a test says so, so a "slow" tool is a number, not a sleep. The planner is ScriptedProvider from the capstone's tests/helpers.py.

    The failure table

    Figure 2. Failure classes, what to inject and what to expect

    Failure Injected with Status What the log and counters show
    Tool raises an error FakeTool([TimeoutError(...)]) completed Step not ok, error: TimeoutError: ...; the next prompt contains it
    Malformed arguments Empty, wrong type, bad pattern, null, a list completed Tool never called; Invalid arguments: sku
    Unknown tool name delete_catalog completed Unknown tool 'delete_catalog'.
    Duplicate call Same call twice completed Tool ran once; error: duplicate call skipped
    Slow tools 4 s each, limit 5 s timeout Two steps ran, none after
    Unreadable plan once "this is not json" completed Two model calls for one step
    Unreadable plan twice Two bad replies planner_error Earlier observations kept
    Endless new calls New arguments each time max_steps Exactly the cap in tool calls

    How to read this: each row is one test in test_m4_l6_failure_injection.py; the last column is what it asserts.

    NOTE

    An extra argument such as {"sku": "LUM-VS-100", "admin": true} is not rejected: Pydantic ignores unknown fields unless a model sets extra="forbid".

    Duplicates and retries pull in different directions

    NOTE

    The runner skips a repeated call, whether or not the first call succeeded.

    Predict first. A tool raises TimeoutError on its first call and would succeed on its second. The planner issues the identical call twice, then finishes. How many times does the tool run, and what does step 2 record?

    Answer

    The tool runs once. Step 2 records error: duplicate call skipped (test_a_failed_call_repeated_exactly_is_skipped_too). For a retry, put it inside the tool.

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L6/m4_l6_fakes.py

    def with_retries(
        run: Callable[[LookupArgs, ToolContext], dict[str, Any]],
        attempts: int = 3,
        delays: tuple[float, ...] = (0.1, 0.2),
        retryable: tuple[type[Exception], ...] = (TimeoutError,),
        sleep: Callable[[float], None] = lambda s: None,
    ) -> Callable[[LookupArgs, ToolContext], dict[str, Any]]:
        """Retry only errors that are worth retrying, a fixed number of times, then let the last error out."""
    
        def wrapped(args: LookupArgs, ctx: ToolContext) -> dict[str, Any]:
            for attempt in range(1, attempts + 1):
                try:
                    return run(args, ctx)
                except retryable:
                    if attempt == attempts:
                        raise
                    sleep(delays[attempt - 1])
            raise AssertionError("unreachable")
    
        return wrapped

    Three rules sit in those lines:

    • Retry only errors that can pass on a second try.
    • Cap the attempts.
    • Wait between them. Production code usually adds exponential growth and jitter.

    The Amazon Builders' Library article on timeouts, retries and backoff with jitter warns that when failures come from overload, retries that add load can make matters significantly worse. It also says APIs with side effects are not safe to retry unless they are idempotent.

    Figure 3. A retry inside the tool, then a repeat blocked and a run aborted

    sequenceDiagram accTitle: A retry inside the tool, then a repeat blocked and a run aborted accDescr: The orchestrator asks the planner for a step and the planner proposes a lookup. The retrying tool tries the upstream service three times with short waits and gets a timeout each time, then returns the last error. The orchestrator records a failed step. The planner proposes the identical call again and the orchestrator skips it as a duplicate without calling the tool. The planner then tries new arguments, which fail too, until the step cap ends the run as max steps. participant M as Planner participant O as Orchestrator participant T as Retrying tool participant S as Upstream service O->>M: plan from observations M-->>O: lookup LUM-VS-100 O->>T: call T->>S: attempt 1 S-->>T: timeout T->>S: attempt 2 after 0.1 s S-->>T: timeout T->>S: attempt 3 after 0.2 s S-->>T: timeout T-->>O: TimeoutError, retries used up O->>O: record step, not ok O->>M: plan, now sees the error M-->>O: lookup LUM-VS-100 again O->>O: duplicate call skipped, tool not called O->>M: plan M-->>O: lookup LUM-VS-200 Note over O,M: new arguments keep failing until the step cap ends the run

    How to read this: retries happen inside one step, the duplicate check blocks the same call, and only the step cap stops changing arguments.

    Do not trust the tool's output either

    A tool can also return something the loop cannot read. The runner summarises a products list with len(...), so a tool that returns {"products": None} raises TypeError outside any try block and the run crashes. The test below is an expected failure: it documents a gap in app/ that this course does not edit.

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L6/test_m4_l6_failure_injection.py

    @pytest.mark.xfail(strict=True, raises=TypeError, reason="capstone gap: a malformed tool RESULT is not caught (see the lesson); remove this mark when app/ is fixed")
    def test_a_malformed_tool_result_should_not_crash_the_runner(ctx: ToolContext) -> None:
        tool = FakeTool([{"products": None}])  # valid shape for the registry, but the runner calls len() on it
        run(ScriptedProvider([call({"sku": "LUM-VS-100"}), FINAL]), tool, ctx)

    With strict=True, the test fails the day someone fixes the runner, which is your cue to remove the mark. Until then, checked_result validates a tool's output inside the tool, so a bad shape becomes an ordinary error observation.

    Worked example

    Assumptions: illustrative; three attempts, delays of 0.1 s and 0.2 s, each timed-out attempt takes 2 seconds, workflow limit timeout_s=5.0.

    Attempt Time spent Wait before the next Running total
    1 2.0 s 0.1 s 2.1 s
    2 2.0 s 0.2 s 4.3 s
    3 2.0 s none 6.3 s

    One step takes 6.3 seconds, above the 5 second limit. The limit is checked only between steps, so the run ends as timeout after it. With a 5 second limit, cut the attempt timeout to 1 second or allow two attempts.

    In the interview: "How do you test an agent?" A good answer has three layers: fakes for the loop's logic (limits, failures, retries), evaluation sets for answer quality, and live provider checks. The first runs on every commit with no model.

    Build it

    Run the whole folder. No key is needed.

    cd capstone/fde-product-discovery-copilot
    ./.venv/Scripts/python -m pytest lessons/M4-L6 -q

    In the wall-clock test, change cost_s to 2.0 and predict the number of tool calls first.

    Verified: capstone/fde-product-discovery-copilot/lessons/M4-L6/test_m4_l6_failure_injection.py

    def test_the_wall_clock_limit_stops_the_run_between_steps(ctx: ToolContext) -> None:
        clock = FakeClock()
        tool = FakeTool([OK], clock=clock, cost_s=4.0)  # every call "takes" 4 seconds of fake time
        skus = ["LUM-VS-100", "LUM-VS-200", "LUM-VS-300", "LUM-VS-400"]
        result = run(ScriptedProvider([call({"sku": s}) for s in skus]), tool, ctx, timeout_s=5.0, clock=clock)
        assert result.status == "timeout" and tool.calls == 2  # 0 s, then 4 s: both under 5; 8 s: stop
        assert [s.elapsed_ms for s in result.steps] == [4000.0, 4000.0]

    With cost_s=2.0 the fake clock reads 0, 2, 4 and 6 at the four step checks. Six is above 5, so the fourth check stops the run: three tool calls, status timeout.

    Exercise

    A tool returns {"found": {1, 2}}, a Python set, which cannot become JSON. Reproduce the failure and contain it.

    Acceptance criteria:

    • An expected-failure test with a fake tool that returns a set shows the crash today.
    • A wrapper called json_safe makes the same tool end as a failed step with the run completed.
    • The wrapper has its own test, and no test sleeps.

    Done when both tests pass and the first one is still marked xfail(strict=True). A solution is in lessons/M4-L6/m4_l6_solution_json_safe.py.

    Hint 1: where it crashes

    The crash comes one step later, when the prompt is built.

    Hint 2: what the wrapper does

    Notice the problem inside the tool. Which standard-library call fails on a set?

    Common mistakes

    • Testing against the real model or service. Use fakes and keep live checks separate.
    • Asserting only the status. Also assert the call counters and the step log.
    • Retrying at several layers. Retries in the tool, the client and the loop multiply load.
    • Retrying calls that change something. A retry can do the action twice.

    Knowledge check

    1. Why count calls on the fake tool instead of only checking the run's status?

    completed looks the same whether a bad call was rejected early or failed inside the tool. calls == 0 proves validation stopped it, and calls == 1 proves a duplicate was skipped.

    2. The planner repeats a call that timed out, with identical arguments. Why does the runner skip it, and what do you do about transient errors?

    An identical repeat usually means the plan is looping, so the key ignores whether the first call failed. Retry inside the tool, so the runner sees one step with the final outcome.

    3. Three attempts, 2 seconds each, waits of 0.1 s and 0.2 s. Why is that a problem with a 5 second limit?

    One step takes 2 + 0.1 + 2 + 0.2 + 2 = 6.3 seconds, above 5. The limit is checked only between steps, so the run ends as timeout right after it.

    4. Why mark the malformed-result test `xfail(strict=True)` and not fix the runner or delete the test?

    This lesson does not edit the runner, so the gap is documented instead. A strict expected failure passes while the bug exists and fails when someone fixes it, which tells you to remove the mark.

    5. An unknown extra argument, `admin: true`, runs the tool. Is the validation broken?

    No, Pydantic ignores unknown fields by default, so the schema did what it was told. If a tool must never receive unexpected fields, set extra="forbid" on its argument model and add a test.

    You can now

    • Drive WorkflowRunner with fakes.
    • Test the five failure classes and assert on counters, status and the step log.
    • Put a bounded retry inside a tool and check it against the time limit.
    • Explain why a tool's output needs validating too.

    Further reading

    Last verified: 2026-11-02

    Next

    This is the last core lesson of the module on bounded agent workflows. The next module starts with Where the Python service sits in the estate.

    Enroll to keep reading

    Lesson 6 of 8 · Forward Deployed Engineer: Agents, Tools and Guardrails

    Write pytest failure-injection tests for a tool-calling workflow with fake tools and a fake clock: tool errors, bad arguments, duplicates, timeouts, retries.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Agents, Tools and Guardrails, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments