Module adcp.decisioning.mock_ad_server
Anti-façade traffic counters for adopter platforms.
AdCP's anti-façade contract (PR #3816 in the spec) catches platform
methods that return spec-valid envelopes without doing any underlying
work — sync_creatives returning [], create_media_buy
fabricating an id without persisting it, get_media_buy_delivery
returning empty arrays. Storyboard runners need a way to prove that
the seller actually called its upstream ad server / database / queue,
not just produced shapes.
This module gives adopters a small Protocol they wire into their
platform impl and a /_debug/traffic HTTP endpoint runners can poll.
The platform method calls mock_ad_server.record_call("creative.upload",
{...}) before returning; the runner asserts ``traffic["creative.upload"]
0`` after a sync_creatives storyboard step.
It composes with framework-side inbound traffic recording (issue #347):
the mock ad server counts outbound (platform → upstream) calls, and
the framework middleware counts inbound (buyer → server) calls. Both
expose JSON over the same /_debug/traffic surface.
Production deployments stay closed: the endpoint is gated behind a
serve() kwarg that defaults to False. Reference / dev sellers
flip it on; production sellers leave it off and the endpoint returns
404.
Example
::
from adcp.decisioning.mock_ad_server import (
InMemoryMockAdServer, MockAdServer,
)
class MyPlatform(DecisioningPlatform, SalesPlatform):
def __init__(self, *, mock_ad_server: MockAdServer | None = None):
self._mock = mock_ad_server
async def sync_creatives(self, req, ctx):
# ... call upstream ad server, persist, etc. ...
if self._mock is not None:
self._mock.record_call(
"creative.upload",
{"count": len(req.creatives)},
)
return SyncCreativesSuccessResponse(creatives=[...])
serve(
MyPlatform(mock_ad_server=InMemoryMockAdServer()),
enable_debug_endpoints=True,
debug_public=True, # local/storyboard only
)
Classes
class InMemoryMockAdServer-
Expand source code
class InMemoryMockAdServer: """Default thread-safe :class:`MockAdServer` implementation. Uses a ``threading.Lock`` rather than an ``asyncio.Lock`` because the recorder is called from both async platform methods AND from sync platform methods (which the framework dispatches on a ``ThreadPoolExecutor``). A threading lock is correct in both contexts; an asyncio lock would deadlock when acquired from a sync method on a worker thread that has no running event loop. Contention is negligible — every operation is an in-memory dict increment, microseconds at most. """ def __init__(self) -> None: self._counts: dict[str, int] = {} self._lock = threading.Lock() def record_call(self, method: str, args: dict[str, Any]) -> None: # ``args`` is intentionally ignored — the count is what matters # for anti-façade assertions, and persisting buyer-supplied # args in-memory across requests is a footgun (PII retention, # unbounded growth). Adopters who want full call records wire # their own MockAdServer impl. del args with self._lock: self._counts[method] = self._counts.get(method, 0) + 1 def get_traffic(self) -> dict[str, int]: with self._lock: return dict(self._counts) def reset(self) -> None: with self._lock: self._counts.clear()Default thread-safe :class:
MockAdServerimplementation.Uses a
threading.Lockrather than anasyncio.Lockbecause the recorder is called from both async platform methods AND from sync platform methods (which the framework dispatches on aThreadPoolExecutor). A threading lock is correct in both contexts; an asyncio lock would deadlock when acquired from a sync method on a worker thread that has no running event loop. Contention is negligible — every operation is an in-memory dict increment, microseconds at most.Methods
def get_traffic(self) ‑> dict[str, int]-
Expand source code
def get_traffic(self) -> dict[str, int]: with self._lock: return dict(self._counts) def record_call(self, method: str, args: dict[str, Any]) ‑> None-
Expand source code
def record_call(self, method: str, args: dict[str, Any]) -> None: # ``args`` is intentionally ignored — the count is what matters # for anti-façade assertions, and persisting buyer-supplied # args in-memory across requests is a footgun (PII retention, # unbounded growth). Adopters who want full call records wire # their own MockAdServer impl. del args with self._lock: self._counts[method] = self._counts.get(method, 0) + 1 def reset(self) ‑> None-
Expand source code
def reset(self) -> None: with self._lock: self._counts.clear()
class MockAdServer (*args, **kwargs)-
Expand source code
@runtime_checkable class MockAdServer(Protocol): """Outbound-traffic recorder for adopter platform methods. Implementations count named upstream calls so storyboard runners can assert the platform actually did work (anti-façade contract). The Protocol is deliberately minimal — ``record_call`` / ``get_traffic`` / ``reset`` — so adopters can swap in a counter backed by Prometheus, statsd, or a real ad-server SDK without changing the platform's call sites. """ def record_call(self, method: str, args: dict[str, Any]) -> None: """Increment the counter for ``method``. :param method: A dotted name identifying the upstream operation — e.g. ``"creative.upload"``, ``"media_buy.create"``, ``"delivery.read"``. Adopters pick the namespace; runners assert against whatever the platform records. :param args: A dict of call arguments for diagnostic / audit purposes. Implementations MAY ignore the dict (the default in-memory impl does — only the count matters for anti-façade assertions). Implementations that persist or log args MUST treat them as untrusted: they may carry buyer-supplied content. """ ... def get_traffic(self) -> dict[str, int]: """Return a snapshot of current per-method counts. Return value is a fresh dict — callers may mutate it without affecting subsequent reads. Order is implementation-defined. """ ... def reset(self) -> None: """Clear all counters. Test-isolation hook — storyboard runners call this between scenarios so previous-step calls don't leak into the next assertion.""" ...Outbound-traffic recorder for adopter platform methods.
Implementations count named upstream calls so storyboard runners can assert the platform actually did work (anti-façade contract). The Protocol is deliberately minimal —
record_call/get_traffic/reset— so adopters can swap in a counter backed by Prometheus, statsd, or a real ad-server SDK without changing the platform's call sites.Ancestors
- typing.Protocol
- typing.Generic
Methods
def get_traffic(self) ‑> dict[str, int]-
Expand source code
def get_traffic(self) -> dict[str, int]: """Return a snapshot of current per-method counts. Return value is a fresh dict — callers may mutate it without affecting subsequent reads. Order is implementation-defined. """ ...Return a snapshot of current per-method counts.
Return value is a fresh dict — callers may mutate it without affecting subsequent reads. Order is implementation-defined.
def record_call(self, method: str, args: dict[str, Any]) ‑> None-
Expand source code
def record_call(self, method: str, args: dict[str, Any]) -> None: """Increment the counter for ``method``. :param method: A dotted name identifying the upstream operation — e.g. ``"creative.upload"``, ``"media_buy.create"``, ``"delivery.read"``. Adopters pick the namespace; runners assert against whatever the platform records. :param args: A dict of call arguments for diagnostic / audit purposes. Implementations MAY ignore the dict (the default in-memory impl does — only the count matters for anti-façade assertions). Implementations that persist or log args MUST treat them as untrusted: they may carry buyer-supplied content. """ ...Increment the counter for
method.:param method: A dotted name identifying the upstream operation — e.g.
"creative.upload","media_buy.create","delivery.read". Adopters pick the namespace; runners assert against whatever the platform records. :param args: A dict of call arguments for diagnostic / audit purposes. Implementations MAY ignore the dict (the default in-memory impl does — only the count matters for anti-façade assertions). Implementations that persist or log args MUST treat them as untrusted: they may carry buyer-supplied content. def reset(self) ‑> None-
Expand source code
def reset(self) -> None: """Clear all counters. Test-isolation hook — storyboard runners call this between scenarios so previous-step calls don't leak into the next assertion.""" ...Clear all counters. Test-isolation hook — storyboard runners call this between scenarios so previous-step calls don't leak into the next assertion.