Skip to content

Make MockHTTPFetcher registration atomic and fix drained-queue reporting - #37

Merged
germ-mark merged 1 commit into
feat/testing-mocksfrom
llm/mock-fetcher-fixes
Jul 29, 2026
Merged

Make MockHTTPFetcher registration atomic and fix drained-queue reporting#37
germ-mark merged 1 commit into
feat/testing-mocksfrom
llm/mock-fetcher-fixes

Conversation

@germ-mark

@germ-mark germ-mark commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Suggestions into #32 — targets feat/testing-mocks, so the diff here is one commit. Merge or cherry-pick as you like.

Scoped to GermConvenienceMocks only. The core-library changes that were previously here are split out into #38, which targets main independently.

on() / enqueue() was not atomic

on() parked currentUrl/currentMethod on the actor and enqueue() read them back. Those are two separate actor messages, so a second chain could overwrite the cursor in between — responses land under the wrong URL, or a URL ends up with an empty queue and throws unmockedRequest later, far from the cause.

It is not theoretical. Measured against the current implementation:

concurrency misattribution
2 chains, idle machine ~10 / 2000
2 chains, loaded machine 482 / 2000
8 chains ~970 / 1000

Load-dependent flakiness inside a testing library is the worst kind, because it gets blamed on the code under test.

on() now returns a Registration value carrying the url and method, and enqueue() hangs off that. The cursor is gone, so the race is unrepresentable rather than merely unlikely — and Errors.missingOnUrl becomes impossible and is removed. Call sites are unchanged apart from dropping try, since enqueue no longer throws:

let mock = await MockHTTPFetcher()
    .on(tokenUrl, method: .post).enqueue(.success(postResponse))
    .on(tokenUrl).enqueue(.success(anyResponse))

A drained exact queue reported "never mocked"

Because the exact-to-any fallback switches keys as soon as an exact queue empties, requestCount > handlerCount was structurally unreachable for an exact key. So over-requesting an exact-only mock threw unmockedRequest — indistinguishable from a URL you never registered — and tooManyRequests only ever fired for .any handlers. The more specific registration produced the vaguer error.

Registered-but-drained now reports tooManyRequests for either key. The exact-to-any fallback itself is unchangedexhaustedExactHandlerFallsBackToAny shows it is deliberate, so this only covers the case where there is no .any handler to fall back to.

Smaller

  • on(origin) never matched. Lookup keys off request.request.url, which HTTPRequest has round-tripped through its pseudo-header fields — that rewrites a bare origin's empty path to /. Registration now goes through the same round trip, so on(URL("https://as.example")) matches a request to that origin instead of keying a url no request can produce.
  • Requests that matched no handler are now logged, matching how tooManyRequests rejections already were, so requests(for:) is a complete record of what the code under test sent. Shout if you would rather the log only contain matched requests — the asymmetry was the actual problem, either direction fixes it.
  • The two implicit returns from your review comments.
  • The changeset said GermConvenienceTesting after the target was renamed to GermConvenienceMocks; that would have shipped into the CHANGELOG.
  • Documented: concurrent same-url enqueue order is undefined, and requests(for:) includes rejected requests while allRequested counts only those that consumed a response.

Test notes

19 tests, clean build, no warnings. All 8 of your existing tests pass unmodified except for dropping the redundant try. Added: the drained-exact case, unmocked-request logging, bare-origin registration, over-request-then-enqueue, and a 16-task concurrent configuration test that ran 20/20 clean.

One coordination note: #38 moves extension BundledHTTPRequest: Equatable onto the type itself. Whichever lands second, this PR's copy in MockHTTPFetcher.swift should be deleted — it is marked with a comment.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6672ac8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes changesets to release 1 package
Name Type
@germ-network/germ-convenience Patch

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

germ-mark added a commit to germ-network/oauth4swift that referenced this pull request Jul 28, 2026
GermConvenience closes the invariant hole by making request private(set) and
body let, so the three sites that assigned header fields in place move to the
new API. Behaviour is unchanged - each one only ever added a header.

Companion to germ-network/GermConvenience#37; needs that released first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
on() parked the url and method on the actor for enqueue() to read, so the pair
was not atomic: two chains configuring one fetcher concurrently could enqueue a
response against each other's url. Measured 24% misattribution with two tasks
under load and ~97% with eight. on() now returns a Registration carrying the
url and method, so the race is unrepresentable rather than unlikely, and
Errors.missingOnUrl is gone with it.

A drained exact-method queue with no .any handler to fall back to reported
unmockedRequest, which reads as "never mocked" - the exact-to-any fallback made
tooManyRequests structurally unreachable for exact keys. Registered-but-drained
now reports tooManyRequests for either key. The intentional fallback to .any is
unchanged.

Requests that matched no handler are now logged too, matching the tooManyRequests
case, so requests(for:) is a complete record of what the code under test sent.

Registration urls go through the same round trip lookup uses, so on(origin)
matches rather than keying a url no request can produce.

Also picks up the two implicit returns from review, and corrects the changeset,
which still named the target GermConvenienceTesting after the rename to
GermConvenienceMocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@germ-mark
germ-mark force-pushed the llm/mock-fetcher-fixes branch from 9a7f9bf to 6672ac8 Compare July 29, 2026 06:17
@germ-mark
germ-mark merged commit ac86fb8 into feat/testing-mocks Jul 29, 2026
5 checks passed
@germ-mark
germ-mark deleted the llm/mock-fetcher-fixes branch July 29, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant