traffic-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shadowdiff/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """shadowdiff: record real API traffic, replay it, and diff the responses that matter."""
2
+
3
+ __version__ = "0.1.0"
shadowdiff/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """Command-line interface."""
2
+
3
+ from enum import StrEnum
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from shadowdiff import __version__
9
+ from shadowdiff.config import ConfigError, load_config
10
+ from shadowdiff.report import build_report, render
11
+ from shadowdiff.store import Store
12
+
13
+ app = typer.Typer(
14
+ help="Record API traffic, replay it, and report meaningful diffs.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+
19
+ @app.callback()
20
+ def main() -> None:
21
+ """shadowdiff: record → replay → smart diff → report."""
22
+
23
+
24
+ @app.command()
25
+ def version() -> None:
26
+ """Print the shadowdiff version."""
27
+ typer.echo(__version__)
28
+
29
+
30
+ DbOption = typer.Option("shadowdiff.db", "--db", help="SQLite database file.")
31
+
32
+
33
+ @app.command()
34
+ def record(
35
+ target: str = typer.Option(..., help="Upstream base URL to forward to."),
36
+ session: str = typer.Option("default", help="Session name to group recordings under."),
37
+ host: str = typer.Option("127.0.0.1", help="Interface to listen on."),
38
+ port: int = typer.Option(8080, help="Port to listen on."),
39
+ db: str = DbOption,
40
+ ) -> None:
41
+ """Run a recording proxy in front of TARGET."""
42
+ import uvicorn
43
+
44
+ from shadowdiff.proxy import create_app
45
+
46
+ store = Store(db)
47
+ typer.echo(f"Recording session '{session}': http://{host}:{port} -> {target} (db: {db})")
48
+ uvicorn.run(
49
+ create_app(target, store, session),
50
+ host=host,
51
+ port=port,
52
+ log_level="warning",
53
+ access_log=False,
54
+ )
55
+ store.close()
56
+
57
+
58
+ class Format(StrEnum):
59
+ md = "md"
60
+ html = "html"
61
+
62
+
63
+ def _write_report(store: Store, run_id: int, fmt: Format, output: Path | None) -> int:
64
+ report = build_report(store, run_id)
65
+ output = output or Path(f"report.{fmt.value}")
66
+ output.write_text(render(report, fmt.value))
67
+ verdict = "FAIL" if report.exit_code else "PASS"
68
+ typer.echo(
69
+ f"{verdict}: {len(report.breaking)} breaking change(s), "
70
+ f"{len(report.warnings)} warning(s), {sum(report.errors.values())} error(s). "
71
+ f"Report: {output}"
72
+ )
73
+ for g in report.breaking:
74
+ typer.echo(f" BREAKING {g.endpoint} {g.path}: {g.detail} on {g.count} request(s)")
75
+ return report.exit_code
76
+
77
+
78
+ @app.command()
79
+ def replay(
80
+ session: str = typer.Option(..., help="Recorded session to replay."),
81
+ target: str = typer.Option(..., help="Base URL of the version under test."),
82
+ concurrency: int = typer.Option(20, min=1, help="Maximum requests in flight."),
83
+ timeout: float = typer.Option(10.0, min=0.1, help="Per-request timeout in seconds."),
84
+ config: Path = typer.Option(Path("shadowdiff.yaml"), help="Ignore-rules file."),
85
+ output: Path | None = typer.Option(None, "--output", "-o", help="Report file path."),
86
+ fmt: Format = typer.Option(Format.md, "--format", help="Report format."),
87
+ db: str = DbOption,
88
+ ) -> None:
89
+ """Replay SESSION against TARGET, diff responses and write a report.
90
+
91
+ Exits 1 if any breaking difference or request error is found.
92
+ """
93
+ from shadowdiff.replay import run_replay
94
+
95
+ try:
96
+ rules = load_config(config)
97
+ except ConfigError as exc:
98
+ typer.echo(f"error: {exc}", err=True)
99
+ raise typer.Exit(2) from None
100
+ store = Store(db)
101
+ try:
102
+ summary = run_replay(store, session, target, rules, concurrency, timeout)
103
+ except ValueError as exc:
104
+ typer.echo(f"error: {exc}", err=True)
105
+ raise typer.Exit(2) from None
106
+ typer.echo(
107
+ f"Run {summary.run_id}: replayed {summary.total} request(s) in "
108
+ f"{summary.replay_seconds:.2f}s ({summary.requests_per_second:.0f} req/s), "
109
+ f"{summary.passed} passed, {summary.failed} failed"
110
+ )
111
+ code = _write_report(store, summary.run_id, fmt, output)
112
+ store.close()
113
+ raise typer.Exit(code)
114
+
115
+
116
+ @app.command()
117
+ def report(
118
+ run: int | None = typer.Option(None, "--run", help="Run ID (default: latest run)."),
119
+ output: Path | None = typer.Option(None, "--output", "-o", help="Report file path."),
120
+ fmt: Format = typer.Option(Format.md, "--format", help="Report format."),
121
+ db: str = DbOption,
122
+ ) -> None:
123
+ """Write the report for a replay run. Exits 1 if it has breaking diffs."""
124
+ store = Store(db)
125
+ run_id = run if run is not None else store.latest_run_id()
126
+ if run_id is None or store.run(run_id) is None:
127
+ typer.echo("error: no such run" if run is not None else "error: no runs yet", err=True)
128
+ raise typer.Exit(2)
129
+ code = _write_report(store, run_id, fmt, output)
130
+ store.close()
131
+ raise typer.Exit(code)
shadowdiff/config.py ADDED
@@ -0,0 +1,57 @@
1
+ """Loading of shadowdiff.yaml ignore rules."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ VALUE_PATTERNS = ("uuid", "iso_timestamp")
9
+
10
+
11
+ class ConfigError(ValueError):
12
+ pass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Config:
17
+ # Globs matched against a field name (e.g. "*_at"), or against the full
18
+ # JSON path when the pattern starts with "$" (e.g. "$.meta.*").
19
+ ignore_fields: tuple[str, ...] = ()
20
+ # Named value patterns: a change between two values matching the same
21
+ # pattern is noise (e.g. one UUID replaced by another).
22
+ ignore_values: tuple[str, ...] = ()
23
+ # JSON path of a list (indices written as [*]) -> key to match items by,
24
+ # or None to compare the list as a sorted multiset.
25
+ unordered_lists: dict[str, str | None] = field(default_factory=dict)
26
+
27
+
28
+ def load_config(path: str | Path | None) -> Config:
29
+ if path is None:
30
+ return Config()
31
+ path = Path(path)
32
+ if not path.exists():
33
+ return Config()
34
+ data = yaml.safe_load(path.read_text()) or {}
35
+ if not isinstance(data, dict):
36
+ raise ConfigError(f"{path}: expected a mapping at the top level")
37
+
38
+ unknown = set(data) - {"ignore_fields", "ignore_values", "unordered_lists"}
39
+ if unknown:
40
+ raise ConfigError(f"{path}: unknown keys: {', '.join(sorted(unknown))}")
41
+
42
+ ignore_values = tuple(data.get("ignore_values") or ())
43
+ bad = [v for v in ignore_values if v not in VALUE_PATTERNS]
44
+ if bad:
45
+ raise ConfigError(
46
+ f"{path}: unknown ignore_values {bad}; supported: {', '.join(VALUE_PATTERNS)}"
47
+ )
48
+
49
+ unordered = data.get("unordered_lists") or {}
50
+ if not isinstance(unordered, dict):
51
+ raise ConfigError(f"{path}: unordered_lists must map a list path to a key")
52
+
53
+ return Config(
54
+ ignore_fields=tuple(str(f) for f in data.get("ignore_fields") or ()),
55
+ ignore_values=ignore_values,
56
+ unordered_lists={str(k): (None if v is None else str(v)) for k, v in unordered.items()},
57
+ )
shadowdiff/diff.py ADDED
@@ -0,0 +1,240 @@
1
+ """Structural response diff with noise filtering.
2
+
3
+ Walks two JSON documents and reports each difference with a JSON path and a
4
+ kind. Ignore rules from ``Config`` drop known noise (timestamps, generated IDs,
5
+ reordered lists) so only meaningful changes are reported.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from collections import Counter
11
+ from dataclasses import dataclass
12
+ from functools import lru_cache
13
+ from typing import Any
14
+
15
+ from shadowdiff.config import Config
16
+
17
+ VALUE_CHANGED = "value_changed"
18
+ TYPE_CHANGED = "type_changed"
19
+ FIELD_ADDED = "field_added"
20
+ FIELD_REMOVED = "field_removed"
21
+ STATUS_CHANGED = "status_changed"
22
+
23
+ BREAKING_KINDS = frozenset({TYPE_CHANGED, FIELD_REMOVED, STATUS_CHANGED})
24
+
25
+ VALUE_REGEXES = {
26
+ "uuid": re.compile(
27
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
28
+ ),
29
+ "iso_timestamp": re.compile(
30
+ r"^\d{4}-\d{2}-\d{2}"
31
+ r"([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$"
32
+ ),
33
+ }
34
+
35
+ _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
36
+ _INDEX = re.compile(r"\[(\d+|[^\[\]]*=[^\[\]]*)\]")
37
+ _MISSING = object()
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Difference:
42
+ path: str
43
+ kind: str
44
+ old: Any = None
45
+ new: Any = None
46
+ old_type: str | None = None
47
+ new_type: str | None = None
48
+
49
+ @property
50
+ def breaking(self) -> bool:
51
+ return self.kind in BREAKING_KINDS
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ d = {"path": self.path, "kind": self.kind, "old": self.old, "new": self.new}
55
+ if self.kind == TYPE_CHANGED:
56
+ d["old_type"] = self.old_type
57
+ d["new_type"] = self.new_type
58
+ d["breaking"] = self.breaking
59
+ return d
60
+
61
+
62
+ def compare(
63
+ old_status: int, old_body: bytes, new_status: int, new_body: bytes, config: Config
64
+ ) -> list[Difference]:
65
+ """Compare a recorded response with a replayed one."""
66
+ if old_status != new_status:
67
+ return [Difference("$", STATUS_CHANGED, old_status, new_status)]
68
+
69
+ old_doc, old_is_json = _parse(old_body)
70
+ new_doc, new_is_json = _parse(new_body)
71
+ if old_is_json and new_is_json:
72
+ return diff_json(old_doc, new_doc, config)
73
+ if old_is_json != new_is_json:
74
+ old_t = json_type(old_doc) if old_is_json else "text"
75
+ new_t = json_type(new_doc) if new_is_json else "text"
76
+ return [Difference("$", TYPE_CHANGED, old_doc, new_doc, old_t, new_t)]
77
+ if old_doc == new_doc or _same_value_pattern(old_doc, new_doc, config):
78
+ return []
79
+ return [Difference("$", VALUE_CHANGED, old_doc, new_doc)]
80
+
81
+
82
+ def diff_json(old: Any, new: Any, config: Config, path: str = "$") -> list[Difference]:
83
+ """Recursively diff two parsed JSON values."""
84
+ out: list[Difference] = []
85
+ _walk(old, new, path, config, out)
86
+ return out
87
+
88
+
89
+ def json_type(value: Any) -> str:
90
+ if value is None:
91
+ return "null"
92
+ if isinstance(value, bool):
93
+ return "boolean"
94
+ if isinstance(value, int | float):
95
+ return "number"
96
+ if isinstance(value, str):
97
+ return "string"
98
+ if isinstance(value, list):
99
+ return "array"
100
+ return "object"
101
+
102
+
103
+ def normalize_path(path: str) -> str:
104
+ """Replace concrete list positions/keys with [*] so paths can be grouped."""
105
+ return _INDEX.sub("[*]", path)
106
+
107
+
108
+ def _parse(body: bytes) -> tuple[Any, bool]:
109
+ if body:
110
+ try:
111
+ return json.loads(body), True
112
+ except ValueError:
113
+ pass
114
+ return body.decode("utf-8", errors="replace"), False
115
+
116
+
117
+ def _walk(old: Any, new: Any, path: str, config: Config, out: list[Difference]) -> None:
118
+ old_t, new_t = json_type(old), json_type(new)
119
+ if old_t != new_t:
120
+ out.append(Difference(path, TYPE_CHANGED, old, new, old_t, new_t))
121
+ elif old_t == "object":
122
+ _walk_object(old, new, path, config, out)
123
+ elif old_t == "array":
124
+ _walk_array(old, new, path, config, out)
125
+ elif old != new and not _same_value_pattern(old, new, config):
126
+ out.append(Difference(path, VALUE_CHANGED, old, new))
127
+
128
+
129
+ def _walk_object(old: dict, new: dict, path: str, config: Config, out: list[Difference]) -> None:
130
+ for key in sorted(old.keys() | new.keys()):
131
+ child = _child_path(path, key)
132
+ if _field_ignored(key, child, config.ignore_fields):
133
+ continue
134
+ o, n = old.get(key, _MISSING), new.get(key, _MISSING)
135
+ if n is _MISSING:
136
+ out.append(Difference(child, FIELD_REMOVED, old=o))
137
+ elif o is _MISSING:
138
+ out.append(Difference(child, FIELD_ADDED, new=n))
139
+ else:
140
+ _walk(o, n, child, config, out)
141
+
142
+
143
+ def _walk_array(old: list, new: list, path: str, config: Config, out: list[Difference]) -> None:
144
+ rule = _unordered_rule(path, config)
145
+ if rule is not _MISSING:
146
+ key = rule
147
+ if key is None:
148
+ _walk_multiset(old, new, path, out)
149
+ return
150
+ old_by_key, new_by_key = _index_by(old, key), _index_by(new, key)
151
+ if old_by_key is not None and new_by_key is not None:
152
+ for k in sorted(old_by_key.keys() | new_by_key.keys(), key=json.dumps):
153
+ child = f"{path}[{key}={json.dumps(k)}]"
154
+ o, n = old_by_key.get(k, _MISSING), new_by_key.get(k, _MISSING)
155
+ if n is _MISSING:
156
+ out.append(Difference(child, FIELD_REMOVED, old=o))
157
+ elif o is _MISSING:
158
+ out.append(Difference(child, FIELD_ADDED, new=n))
159
+ else:
160
+ _walk(o, n, child, config, out)
161
+ return
162
+ # Items without a unique key: fall back to positional comparison.
163
+
164
+ for i in range(max(len(old), len(new))):
165
+ child = f"{path}[{i}]"
166
+ if i >= len(new):
167
+ out.append(Difference(child, FIELD_REMOVED, old=old[i]))
168
+ elif i >= len(old):
169
+ out.append(Difference(child, FIELD_ADDED, new=new[i]))
170
+ else:
171
+ _walk(old[i], new[i], child, config, out)
172
+
173
+
174
+ def _walk_multiset(old: list, new: list, path: str, out: list[Difference]) -> None:
175
+ canon = [json.dumps(x, sort_keys=True) for x in old]
176
+ new_canon = [json.dumps(x, sort_keys=True) for x in new]
177
+ removed = Counter(canon) - Counter(new_canon)
178
+ added = Counter(new_canon) - Counter(canon)
179
+ for i, c in enumerate(canon):
180
+ if removed[c]:
181
+ removed[c] -= 1
182
+ out.append(Difference(f"{path}[{i}]", FIELD_REMOVED, old=old[i]))
183
+ for i, c in enumerate(new_canon):
184
+ if added[c]:
185
+ added[c] -= 1
186
+ out.append(Difference(f"{path}[{i}]", FIELD_ADDED, new=new[i]))
187
+
188
+
189
+ def _index_by(items: list, key: str) -> dict | None:
190
+ """Map item[key] -> item, or None if any item lacks a unique, hashable key."""
191
+ index: dict = {}
192
+ for item in items:
193
+ if not isinstance(item, dict) or key not in item:
194
+ return None
195
+ k = item[key]
196
+ if isinstance(k, dict | list) or k in index:
197
+ return None
198
+ index[k] = item
199
+ return index
200
+
201
+
202
+ def _unordered_rule(path: str, config: Config) -> Any:
203
+ if not config.unordered_lists:
204
+ return _MISSING
205
+ if path in config.unordered_lists:
206
+ return config.unordered_lists[path]
207
+ return config.unordered_lists.get(normalize_path(path), _MISSING)
208
+
209
+
210
+ def _field_ignored(key: str, path: str, patterns: tuple[str, ...]) -> bool:
211
+ for pattern in patterns:
212
+ if pattern.startswith("$"):
213
+ rx = _glob(pattern)
214
+ if rx.match(path) or rx.match(normalize_path(path)):
215
+ return True
216
+ elif _glob(pattern).match(key):
217
+ return True
218
+ return False
219
+
220
+
221
+ @lru_cache(maxsize=256)
222
+ def _glob(pattern: str) -> re.Pattern:
223
+ """Compile a glob where only * and ? are special (brackets are literal)."""
224
+ parts = (".*" if c == "*" else "." if c == "?" else re.escape(c) for c in pattern)
225
+ return re.compile("".join(parts) + r"\Z")
226
+
227
+
228
+ def _same_value_pattern(old: Any, new: Any, config: Config) -> bool:
229
+ if not (isinstance(old, str) and isinstance(new, str)):
230
+ return False
231
+ return any(
232
+ VALUE_REGEXES[name].match(old) and VALUE_REGEXES[name].match(new)
233
+ for name in config.ignore_values
234
+ )
235
+
236
+
237
+ def _child_path(path: str, key: str) -> str:
238
+ if _IDENTIFIER.match(key):
239
+ return f"{path}.{key}"
240
+ return f"{path}[{json.dumps(key)}]"
shadowdiff/proxy.py ADDED
@@ -0,0 +1,191 @@
1
+ """Recording reverse proxy.
2
+
3
+ Forwards every request to an upstream unchanged and records ``GET`` exchanges
4
+ with 2xx-4xx responses. Recording happens off the response path: exchanges go
5
+ onto an in-memory queue that a background task flushes to SQLite in batches.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import time
11
+
12
+ import httpx
13
+ from fastapi import FastAPI, Request, Response
14
+
15
+ from shadowdiff.store import Exchange, Store
16
+
17
+ MAX_BODY_BYTES = 1024 * 1024
18
+ HOP_BY_HOP = frozenset(
19
+ {
20
+ "connection",
21
+ "keep-alive",
22
+ "proxy-authenticate",
23
+ "proxy-authorization",
24
+ "te",
25
+ "trailer",
26
+ "trailers",
27
+ "transfer-encoding",
28
+ "upgrade",
29
+ "host",
30
+ "content-length",
31
+ }
32
+ )
33
+ # httpx transparently decodes compressed bodies, so the encoding header no longer applies.
34
+ STRIP_FROM_RESPONSE = HOP_BY_HOP | {"content-encoding"}
35
+
36
+
37
+ def strip_headers(headers, drop: frozenset[str] = HOP_BY_HOP) -> dict[str, str]:
38
+ return {k: v for k, v in headers.items() if k.lower() not in drop}
39
+
40
+
41
+ def should_record(method: str, status: int, req_body: bytes, resp_body: bytes) -> bool:
42
+ return (
43
+ method == "GET"
44
+ and 200 <= status < 500
45
+ and len(req_body) <= MAX_BODY_BYTES
46
+ and len(resp_body) <= MAX_BODY_BYTES
47
+ )
48
+
49
+
50
+ class Recorder:
51
+ """Buffers exchanges and writes them to the store in batches."""
52
+
53
+ _STOP = object()
54
+
55
+ def __init__(self, store: Store, batch_size: int = 200):
56
+ self.store = store
57
+ self.batch_size = batch_size
58
+ self.queue: asyncio.Queue = asyncio.Queue()
59
+ self.recorded = 0
60
+
61
+ def submit(self, exchange: Exchange) -> None:
62
+ self.queue.put_nowait(exchange)
63
+
64
+ def stop(self) -> None:
65
+ """Ask run() to flush everything submitted so far, then return."""
66
+ self.queue.put_nowait(self._STOP)
67
+
68
+ async def run(self) -> None:
69
+ # Stopping with a sentinel rather than cancelling means a batch is never
70
+ # abandoned mid-write and nothing submitted before stop() is lost.
71
+ stopping = False
72
+ while not stopping:
73
+ batch = []
74
+ item = await self.queue.get()
75
+ while True:
76
+ if item is self._STOP:
77
+ stopping = True
78
+ break
79
+ batch.append(item)
80
+ if len(batch) >= self.batch_size or self.queue.empty():
81
+ break
82
+ item = self.queue.get_nowait()
83
+ if batch:
84
+ await asyncio.to_thread(self.store.add_exchanges, batch)
85
+ self.recorded += len(batch)
86
+
87
+
88
+ class ClientPool:
89
+ """A growable LIFO pool of single-connection httpx clients.
90
+
91
+ One shared client with a large connection pool spends CPU per request
92
+ proportional to the pool size, which added tens of milliseconds of p95
93
+ latency under concurrent load. Single-connection clients avoid that, and
94
+ LIFO reuse keeps the warmest connections busy.
95
+ """
96
+
97
+ def __init__(self, factory, max_size: int = 200):
98
+ self.factory = factory
99
+ self.max_size = max_size
100
+ self.idle: asyncio.LifoQueue[httpx.AsyncClient] = asyncio.LifoQueue()
101
+ self.clients: list[httpx.AsyncClient] = []
102
+
103
+ @contextlib.asynccontextmanager
104
+ async def client(self):
105
+ if not self.idle.empty():
106
+ client = self.idle.get_nowait()
107
+ elif len(self.clients) < self.max_size:
108
+ client = self.factory()
109
+ self.clients.append(client)
110
+ else:
111
+ client = await self.idle.get()
112
+ try:
113
+ yield client
114
+ finally:
115
+ self.idle.put_nowait(client)
116
+
117
+ async def aclose(self) -> None:
118
+ for client in self.clients:
119
+ await client.aclose()
120
+
121
+
122
+ def create_app(
123
+ target: str,
124
+ store: Store,
125
+ session: str,
126
+ transport: httpx.AsyncBaseTransport | None = None,
127
+ ) -> FastAPI:
128
+ target = target.rstrip("/")
129
+
130
+ @contextlib.asynccontextmanager
131
+ async def lifespan(app: FastAPI):
132
+ recorder = Recorder(store)
133
+ limits = httpx.Limits(max_connections=1, max_keepalive_connections=1)
134
+ pool = ClientPool(
135
+ lambda: httpx.AsyncClient(
136
+ base_url=target, limits=limits, timeout=30, transport=transport
137
+ )
138
+ )
139
+ app.state.pool = pool
140
+ app.state.recorder = recorder
141
+ writer = asyncio.create_task(recorder.run())
142
+ try:
143
+ yield
144
+ finally:
145
+ recorder.stop()
146
+ await writer
147
+ await pool.aclose()
148
+
149
+ app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
150
+
151
+ @app.api_route(
152
+ "/{path:path}",
153
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
154
+ )
155
+ async def forward(request: Request, path: str) -> Response:
156
+ pool: ClientPool = request.app.state.pool
157
+ req_body = await request.body()
158
+ req_headers = strip_headers(request.headers)
159
+ query = request.url.query
160
+ url = request.url.path + (f"?{query}" if query else "")
161
+
162
+ start = time.perf_counter()
163
+ try:
164
+ async with pool.client() as client:
165
+ upstream = await client.request(
166
+ request.method, url, headers=req_headers, content=req_body
167
+ )
168
+ except httpx.HTTPError as exc:
169
+ return Response(f"shadowdiff proxy: upstream error: {exc!r}", status_code=502)
170
+ latency_ms = (time.perf_counter() - start) * 1000
171
+
172
+ resp_headers = strip_headers(upstream.headers, STRIP_FROM_RESPONSE)
173
+ resp_body = upstream.content
174
+ if should_record(request.method, upstream.status_code, req_body, resp_body):
175
+ request.app.state.recorder.submit(
176
+ Exchange(
177
+ session=session,
178
+ method=request.method,
179
+ path=request.url.path,
180
+ query=query,
181
+ req_headers=req_headers,
182
+ req_body=req_body,
183
+ status=upstream.status_code,
184
+ resp_headers=resp_headers,
185
+ resp_body=resp_body,
186
+ latency_ms=latency_ms,
187
+ )
188
+ )
189
+ return Response(content=resp_body, status_code=upstream.status_code, headers=resp_headers)
190
+
191
+ return app