Skip to content

Add simulated network failures (ThrowsException / TimesOut) for resilience testing #116

@dennisdoomen

Description

@dennisdoomen

Summary

Allow a mock to throw/simulate a network-level failure instead of returning a response, so tests can verify retry, circuit-breaker, and error-handling code.

Motivation

Resilient HTTP code must handle transport failures (HttpRequestException), timeouts (TaskCanceledException), and socket errors. There is no first-class way to simulate these today — and note that the current pipeline actually swallows responder exceptions: RequestMock.TrackRequest catches any exception and converts it into a 500 response, so a user cannot simulate a thrown transport error even via RespondsWith(_ => throw ...).

Proposed API (additive)

RequestMockResponseBuilder ThrowsException(Exception exception);
RequestMockResponseBuilder ThrowsException<TException>() where TException : Exception, new();
RequestMockResponseBuilder TimesOut(); // throws TaskCanceledException, mimicking HttpClient timeout

Usage:

mock.ForGet().WithPath("/flaky").ThrowsException<HttpRequestException>();

Implementation plan

dennisdoomen/mockly (core)

  • Add an async response path (see shared dependency below) where an "exception responder" is awaited and the exception is propagated to the caller rather than caught.
  • Carefully scope the existing try/catch in TrackRequest (lines ~268–280): distinguish "intentionally simulated transport failure" (propagate) from "bug in a user responder" (current 500 behavior). Represent simulated failures as a dedicated responder type that the pipeline rethrows before/around the catch.
  • Ensure the thrown request is still recorded in HttpMock.Requests (as expected/handled) before throwing.
  • Tests: HttpRequestException surfaces to HttpClient caller; TimesOut() throws TaskCanceledException; the failed call is captured in Requests; Once()/Times(n) still apply.

dennisdoomen/fluentassertions.mockly (assertions — companion work)

  • CapturedRequestAssertions currently has BeExpected() / BeUnexpected(). Add:
    BeASimulatedFailure(string because = "")   // asserts CapturedRequest was a simulated throw, not a real response
    This lets tests verify that the captured entry corresponds to an intended simulated failure rather than an unexpected 500.
  • Both v7/v8 packages must be updated via Shared/.
  • Needs AcceptApiChanges.ps1 in the FA.Mockly repo.

Shared dependency

Shares the async pipeline + CancellationToken work with the latency (#3) and async-responder (#7) issues. Recommend implementing that plumbing once.

Breaking-change analysis

  • None for consumers at the API level (additive methods).
  • Behavioral nuance: scoping the existing catch-all so simulated exceptions propagate is a deliberate change. Existing code relying on a thrown responder producing a 500 is unaffected (opt-in via new methods).
  • Core: api-approved workflow + AcceptApiChanges.ps1 in dennisdoomen/mockly.
  • FA.Mockly: AcceptApiChanges.ps1 in dennisdoomen/fluentassertions.mockly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions