pypproxy 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.
Files changed (72) hide show
  1. pypproxy/__init__.py +0 -0
  2. pypproxy/api/__init__.py +0 -0
  3. pypproxy/api/server.py +427 -0
  4. pypproxy/bulk/__init__.py +0 -0
  5. pypproxy/bulk/sender.py +97 -0
  6. pypproxy/cert/__init__.py +0 -0
  7. pypproxy/cert/ca.py +144 -0
  8. pypproxy/cert/client_cert.py +65 -0
  9. pypproxy/codec.py +176 -0
  10. pypproxy/config/__init__.py +0 -0
  11. pypproxy/config/config.py +106 -0
  12. pypproxy/dns/__init__.py +0 -0
  13. pypproxy/dns/server.py +149 -0
  14. pypproxy/exporter/__init__.py +0 -0
  15. pypproxy/exporter/exporter.py +122 -0
  16. pypproxy/exporter/importer.py +169 -0
  17. pypproxy/graphql/__init__.py +0 -0
  18. pypproxy/graphql/detector.py +76 -0
  19. pypproxy/graphql/introspection.py +217 -0
  20. pypproxy/graphql/modifier.py +98 -0
  21. pypproxy/graphql/schema_store.py +33 -0
  22. pypproxy/intercept/__init__.py +0 -0
  23. pypproxy/intercept/manager.py +142 -0
  24. pypproxy/interceptor/__init__.py +0 -0
  25. pypproxy/interceptor/interceptor.py +172 -0
  26. pypproxy/proto/__init__.py +0 -0
  27. pypproxy/proto/grpc.py +48 -0
  28. pypproxy/proto/mqtt.py +119 -0
  29. pypproxy/proto/ws.py +120 -0
  30. pypproxy/proto/ws_intercept.py +117 -0
  31. pypproxy/proxy/__init__.py +0 -0
  32. pypproxy/proxy/proxy.py +407 -0
  33. pypproxy/replay/__init__.py +0 -0
  34. pypproxy/replay/replay.py +77 -0
  35. pypproxy/rule/__init__.py +0 -0
  36. pypproxy/rule/rule.py +198 -0
  37. pypproxy/scan/__init__.py +0 -0
  38. pypproxy/scan/scanner.py +296 -0
  39. pypproxy/script/__init__.py +0 -0
  40. pypproxy/script/engine.py +49 -0
  41. pypproxy/security/__init__.py +0 -0
  42. pypproxy/security/header_checker.py +308 -0
  43. pypproxy/security/int_overflow.py +193 -0
  44. pypproxy/security/jwt_checker.py +273 -0
  45. pypproxy/security/plugin.py +152 -0
  46. pypproxy/security/randomness.py +165 -0
  47. pypproxy/store/__init__.py +0 -0
  48. pypproxy/store/db.py +189 -0
  49. pypproxy/store/filter_parser.py +181 -0
  50. pypproxy/store/fts.py +105 -0
  51. pypproxy/store/models.py +81 -0
  52. pypproxy/store/scope.py +63 -0
  53. pypproxy/store/store.py +120 -0
  54. pypproxy/ui/__init__.py +0 -0
  55. pypproxy/ui/app.py +386 -0
  56. pypproxy/ui/bulk_sender_ui.py +125 -0
  57. pypproxy/ui/cui.py +162 -0
  58. pypproxy/ui/detail.py +179 -0
  59. pypproxy/ui/diff_view.py +118 -0
  60. pypproxy/ui/graphql_tab.py +265 -0
  61. pypproxy/ui/import_tab.py +136 -0
  62. pypproxy/ui/intercept_dialog.py +74 -0
  63. pypproxy/ui/resender.py +140 -0
  64. pypproxy/ui/scan_tab.py +98 -0
  65. pypproxy/ui/security_tab.py +356 -0
  66. pypproxy/ui/settings.py +413 -0
  67. pypproxy/ui/theme.py +59 -0
  68. pypproxy-0.1.0.dist-info/METADATA +19 -0
  69. pypproxy-0.1.0.dist-info/RECORD +72 -0
  70. pypproxy-0.1.0.dist-info/WHEEL +4 -0
  71. pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
  72. pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @dataclass
11
+ class WSFrame:
12
+ direction: str # client | server
13
+ opcode: int
14
+ payload: bytes
15
+ entry_id: int
16
+
17
+ def text(self) -> str:
18
+ if self.opcode == 1:
19
+ return self.payload.decode("utf-8", errors="replace")
20
+ return f"<binary {len(self.payload)} bytes>"
21
+
22
+ def to_dict(self) -> dict:
23
+ import base64
24
+
25
+ return {
26
+ "direction": self.direction,
27
+ "opcode": self.opcode,
28
+ "payload": base64.b64encode(self.payload).decode() if self.payload else "",
29
+ "text": self.text(),
30
+ "entry_id": self.entry_id,
31
+ }
32
+
33
+
34
+ class WSInterceptManager:
35
+ """
36
+ Intercepts WebSocket frames for manual review, similar to HTTP intercept.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._enabled = False
41
+ self._pending: dict[int, tuple[WSFrame, asyncio.Event, list[bytes]]] = {}
42
+ self._counter = 0
43
+ self._lock = asyncio.Lock()
44
+ self._subscribers: list[asyncio.Queue] = []
45
+
46
+ @property
47
+ def enabled(self) -> bool:
48
+ return self._enabled
49
+
50
+ def set_enabled(self, value: bool) -> None:
51
+ self._enabled = value
52
+ if not value:
53
+ # release all pending frames
54
+ for frame_id in list(self._pending):
55
+ _, event, _ = self._pending[frame_id]
56
+ event.set()
57
+
58
+ async def intercept(self, frame: WSFrame) -> bytes:
59
+ """
60
+ Pause the frame for manual review.
61
+ Returns the (possibly edited) payload.
62
+ """
63
+ if not self._enabled:
64
+ return frame.payload
65
+
66
+ async with self._lock:
67
+ self._counter += 1
68
+ frame_id = self._counter
69
+ event = asyncio.Event()
70
+ result: list[bytes] = [frame.payload]
71
+ self._pending[frame_id] = (frame, event, result)
72
+
73
+ self._notify(frame_id, frame)
74
+
75
+ await event.wait()
76
+
77
+ async with self._lock:
78
+ _, _, result = self._pending.pop(frame_id, (None, None, [frame.payload]))
79
+
80
+ return result[0]
81
+
82
+ def forward(self, frame_id: int, payload: bytes | None = None) -> None:
83
+ entry = self._pending.get(frame_id)
84
+ if entry:
85
+ frame, event, result = entry
86
+ if payload is not None:
87
+ result[0] = payload
88
+ event.set()
89
+
90
+ def drop(self, frame_id: int) -> None:
91
+ entry = self._pending.get(frame_id)
92
+ if entry:
93
+ frame, event, result = entry
94
+ result[0] = b"" # empty = drop
95
+ event.set()
96
+
97
+ def list_pending(self) -> list[dict]:
98
+ return [{"id": fid, **frame.to_dict()} for fid, (frame, _, _) in self._pending.items()]
99
+
100
+ def subscribe(self) -> asyncio.Queue:
101
+ q: asyncio.Queue = asyncio.Queue(maxsize=128)
102
+ self._subscribers.append(q)
103
+ return q
104
+
105
+ def unsubscribe(self, q: asyncio.Queue) -> None:
106
+ import contextlib
107
+
108
+ with contextlib.suppress(ValueError):
109
+ self._subscribers.remove(q)
110
+
111
+ def _notify(self, frame_id: int, frame: WSFrame) -> None:
112
+ import contextlib
113
+
114
+ data = {"id": frame_id, **frame.to_dict()}
115
+ for q in self._subscribers:
116
+ with contextlib.suppress(asyncio.QueueFull):
117
+ q.put_nowait(data)
File without changes
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import ssl
6
+ import time
7
+ from urllib.parse import urlparse
8
+
9
+ from pypproxy.cert.ca import CA
10
+ from pypproxy.intercept.manager import InterceptManager
11
+ from pypproxy.interceptor.interceptor import Interceptor
12
+ from pypproxy.proto import grpc as grpc_proto
13
+ from pypproxy.proto import ws as ws_proto
14
+ from pypproxy.script.engine import ScriptEngine
15
+ from pypproxy.store.store import Store
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ HTTP_200_CONNECT = b"HTTP/1.1 200 Connection Established\r\n\r\n"
20
+
21
+
22
+ class Proxy:
23
+ def __init__(
24
+ self,
25
+ ca: CA,
26
+ interceptor: Interceptor,
27
+ store: Store,
28
+ script: ScriptEngine | None = None,
29
+ ignore: set[str] | None = None,
30
+ intercept_manager: InterceptManager | None = None,
31
+ ) -> None:
32
+ self._ca = ca
33
+ self._interceptor = interceptor
34
+ self._store = store
35
+ self._script = script
36
+ self._ignore = ignore or set()
37
+ self._intercept = intercept_manager
38
+
39
+ async def handle(
40
+ self,
41
+ reader: asyncio.StreamReader,
42
+ writer: asyncio.StreamWriter,
43
+ ) -> None:
44
+ try:
45
+ request_line = await reader.readline()
46
+ if not request_line:
47
+ return
48
+ headers_raw = await _read_headers(reader)
49
+ headers = _parse_headers(headers_raw)
50
+
51
+ parts = request_line.decode(errors="replace").split()
52
+ if len(parts) < 3:
53
+ return
54
+ method, target, _ = parts[0], parts[1], parts[2]
55
+
56
+ if method == "CONNECT":
57
+ await self._handle_connect(reader, writer, target, headers)
58
+ else:
59
+ await self._handle_http(reader, writer, method, target, headers, scheme="http")
60
+ except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
61
+ pass
62
+ finally:
63
+ writer.close()
64
+
65
+ async def _handle_connect(
66
+ self,
67
+ reader: asyncio.StreamReader,
68
+ writer: asyncio.StreamWriter,
69
+ target: str,
70
+ headers: dict,
71
+ ) -> None:
72
+ host = target.split(":")[0]
73
+ port = int(target.split(":")[1]) if ":" in target else 443
74
+
75
+ if host in self._ignore:
76
+ await self._tunnel(reader, writer, host, port)
77
+ return
78
+
79
+ writer.write(HTTP_200_CONNECT)
80
+ await writer.drain()
81
+
82
+ ssl_ctx = self._ca.ssl_context_for(host)
83
+ try:
84
+ tls_reader, tls_writer = await asyncio.wait_for(
85
+ self._upgrade_server_tls(reader, writer, ssl_ctx), timeout=10
86
+ )
87
+ except (TimeoutError, ssl.SSLError, OSError) as e:
88
+ logger.debug("TLS handshake failed for %s: %s", host, e)
89
+ return
90
+
91
+ try:
92
+ await self._serve_decrypted(tls_reader, tls_writer, host, port)
93
+ finally:
94
+ tls_writer.close()
95
+
96
+ async def _upgrade_server_tls(
97
+ self,
98
+ reader: asyncio.StreamReader,
99
+ writer: asyncio.StreamWriter,
100
+ ssl_ctx: ssl.SSLContext,
101
+ ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
102
+ transport = writer.transport
103
+ loop = asyncio.get_event_loop()
104
+ tls_transport = await loop.start_tls(
105
+ transport, transport.get_protocol(), ssl_ctx, server_side=True
106
+ )
107
+ tls_reader = asyncio.StreamReader()
108
+ tls_protocol = asyncio.StreamReaderProtocol(tls_reader)
109
+ tls_transport.set_protocol(tls_protocol)
110
+ tls_writer = asyncio.StreamWriter(tls_transport, tls_protocol, tls_reader, loop)
111
+ return tls_reader, tls_writer
112
+
113
+ async def _serve_decrypted(
114
+ self,
115
+ reader: asyncio.StreamReader,
116
+ writer: asyncio.StreamWriter,
117
+ host: str,
118
+ port: int,
119
+ ) -> None:
120
+ while True:
121
+ request_line = await reader.readline()
122
+ if not request_line:
123
+ return
124
+ headers_raw = await _read_headers(reader)
125
+ headers = _parse_headers(headers_raw)
126
+
127
+ if ws_proto.is_upgrade(headers):
128
+ await self._handle_websocket(reader, writer, host, port, request_line, headers_raw)
129
+ return
130
+
131
+ parts = request_line.decode(errors="replace").split()
132
+ if len(parts) < 2:
133
+ return
134
+ method, path = parts[0], parts[1]
135
+
136
+ content_length = int(headers.get("content-length", ["0"])[0] or 0)
137
+ body = await reader.read(content_length) if content_length > 0 else b""
138
+
139
+ await self._handle_https(writer, method, host, port, path, headers, body)
140
+
141
+ if headers.get("connection", [""])[0].lower() == "close":
142
+ return
143
+
144
+ async def _handle_http(
145
+ self,
146
+ reader: asyncio.StreamReader,
147
+ writer: asyncio.StreamWriter,
148
+ method: str,
149
+ target: str,
150
+ headers: dict,
151
+ scheme: str,
152
+ ) -> None:
153
+ parsed = urlparse(target if target.startswith("http") else f"http://{target}")
154
+ host = parsed.netloc or parsed.hostname or target
155
+ path = parsed.path or "/"
156
+ if parsed.query:
157
+ path += "?" + parsed.query
158
+ query = parsed.query or ""
159
+
160
+ content_length = int(headers.get("content-length", ["0"])[0] or 0)
161
+ body = await reader.read(content_length) if content_length > 0 else b""
162
+
163
+ response = await self._forward(method, scheme, host, path, query, headers, body)
164
+ writer.write(response)
165
+ await writer.drain()
166
+
167
+ async def _handle_https(
168
+ self,
169
+ writer: asyncio.StreamWriter,
170
+ method: str,
171
+ host: str,
172
+ port: int,
173
+ path: str,
174
+ headers: dict,
175
+ body: bytes,
176
+ ) -> None:
177
+ full_host = f"{host}:{port}" if port != 443 else host
178
+ query = ""
179
+ if "?" in path:
180
+ path, query = path.split("?", 1)
181
+
182
+ response = await self._forward(method, "https", full_host, path, query, headers, body)
183
+ writer.write(response)
184
+ await writer.drain()
185
+
186
+ async def _forward(
187
+ self,
188
+ method: str,
189
+ scheme: str,
190
+ host: str,
191
+ path: str,
192
+ query: str,
193
+ headers: dict,
194
+ body: bytes,
195
+ ) -> bytes:
196
+ import httpx
197
+
198
+ from pypproxy.codec import decode_body
199
+
200
+ if self._script:
201
+ body = self._script.on_request(method, host, path, body)
202
+
203
+ # Manual intercept — pause until user forwards or drops
204
+ if self._intercept:
205
+ headers, body, drop = await self._intercept.intercept(
206
+ method, scheme, host, path, headers, body
207
+ )
208
+ if drop:
209
+ return b"HTTP/1.1 403 Forbidden\r\nContent-Length: 7\r\n\r\ndropped"
210
+
211
+ entry, blocked = self._interceptor.process_request(
212
+ method, scheme, host, path, query, headers, body
213
+ )
214
+
215
+ if blocked:
216
+ return b"HTTP/1.1 403 Forbidden\r\nContent-Length: 7\r\n\r\nblocked"
217
+
218
+ url = f"{scheme}://{host}{path}"
219
+ if query:
220
+ url += "?" + query
221
+
222
+ req_headers = {
223
+ k: ", ".join(v)
224
+ for k, v in headers.items()
225
+ if k.lower() not in ("proxy-connection", "proxy-authorization")
226
+ }
227
+
228
+ start = time.monotonic()
229
+ try:
230
+ async with httpx.AsyncClient(
231
+ verify=False,
232
+ timeout=30,
233
+ http2=True,
234
+ ) as client:
235
+ resp = await client.request(
236
+ method=method,
237
+ url=url,
238
+ headers=req_headers,
239
+ content=entry.req_body,
240
+ follow_redirects=False,
241
+ )
242
+ raw_body = resp.content
243
+ content_encoding = resp.headers.get("content-encoding", "")
244
+ decoded_body, applied_encoding = decode_body(raw_body, content_encoding)
245
+
246
+ if self._script:
247
+ decoded_body = self._script.on_response(resp.status_code, decoded_body)
248
+
249
+ if grpc_proto.is_grpc({k: [v] for k, v in resp.headers.items()}):
250
+ grpc_proto.log_frames(entry.id, "response", decoded_body)
251
+
252
+ resp_headers_dict = {}
253
+ for k, v in resp.headers.multi_items():
254
+ resp_headers_dict.setdefault(k.lower(), []).append(v)
255
+
256
+ # Store decoded body so UI can display plain text
257
+ self._interceptor.process_response(
258
+ entry, resp.status_code, resp_headers_dict, decoded_body, start
259
+ )
260
+
261
+ # Forward original (encoded) body to client unless script modified it
262
+ forward_body = decoded_body if self._script else raw_body
263
+ # Strip content-encoding header if we decoded the body to forward as-is
264
+ forward_headers = list(resp.headers.multi_items())
265
+ if applied_encoding and not self._script:
266
+ pass # keep original encoding; body is untouched
267
+ elif applied_encoding and self._script:
268
+ forward_headers = [
269
+ (k, v) for k, v in forward_headers if k.lower() != "content-encoding"
270
+ ]
271
+ return _build_http_response(resp.status_code, forward_headers, forward_body)
272
+
273
+ except Exception as e:
274
+ logger.warning("upstream error %s %s: %s", method, url, e)
275
+ msg = str(e).encode()
276
+ return _build_http_response(502, [], msg)
277
+
278
+ async def _handle_websocket(
279
+ self,
280
+ client_reader: asyncio.StreamReader,
281
+ client_writer: asyncio.StreamWriter,
282
+ host: str,
283
+ port: int,
284
+ request_line: bytes,
285
+ headers_raw: bytes,
286
+ ) -> None:
287
+ from pypproxy.store.models import Entry
288
+
289
+ try:
290
+ server_reader, server_writer = await asyncio.open_connection(
291
+ host, port, ssl=ssl.create_default_context()
292
+ )
293
+ except Exception as e:
294
+ logger.warning("ws: cannot connect to %s:%d: %s", host, port, e)
295
+ return
296
+
297
+ server_writer.write(request_line + headers_raw + b"\r\n")
298
+ await server_writer.drain()
299
+
300
+ resp_line = await server_reader.readline()
301
+ resp_headers_raw = await _read_headers(server_reader)
302
+ client_writer.write(resp_line + resp_headers_raw + b"\r\n")
303
+ await client_writer.drain()
304
+
305
+ entry = self._store.add(
306
+ Entry(
307
+ method="GET",
308
+ scheme="wss",
309
+ host=host,
310
+ path="/",
311
+ protocol="ws",
312
+ tags=["websocket"],
313
+ )
314
+ )
315
+
316
+ await ws_proto.relay_frames(
317
+ client_reader,
318
+ client_writer,
319
+ server_reader,
320
+ server_writer,
321
+ entry,
322
+ self._store,
323
+ )
324
+
325
+ async def _tunnel(
326
+ self,
327
+ reader: asyncio.StreamReader,
328
+ writer: asyncio.StreamWriter,
329
+ host: str,
330
+ port: int,
331
+ ) -> None:
332
+ writer.write(HTTP_200_CONNECT)
333
+ await writer.drain()
334
+ try:
335
+ server_reader, server_writer = await asyncio.open_connection(host, port)
336
+ except OSError:
337
+ return
338
+
339
+ async def pipe(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
340
+ try:
341
+ while True:
342
+ data = await r.read(65536)
343
+ if not data:
344
+ break
345
+ w.write(data)
346
+ await w.drain()
347
+ except (ConnectionResetError, BrokenPipeError):
348
+ pass
349
+
350
+ await asyncio.gather(
351
+ pipe(reader, server_writer),
352
+ pipe(server_reader, writer),
353
+ )
354
+
355
+
356
+ def _build_http_response(status: int, headers: list[tuple[str, str]], body: bytes) -> bytes:
357
+ reason = _STATUS.get(status, "Unknown")
358
+ lines = [f"HTTP/1.1 {status} {reason}"]
359
+ skip = {"transfer-encoding"}
360
+ for k, v in headers:
361
+ if k.lower() not in skip:
362
+ lines.append(f"{k}: {v}")
363
+ lines.append(f"Content-Length: {len(body)}")
364
+ lines.append("")
365
+ lines.append("")
366
+ return "\r\n".join(lines).encode() + body
367
+
368
+
369
+ async def _read_headers(reader: asyncio.StreamReader) -> bytes:
370
+ buf = b""
371
+ while True:
372
+ line = await reader.readline()
373
+ buf += line
374
+ if line in (b"\r\n", b"\n", b""):
375
+ break
376
+ return buf
377
+
378
+
379
+ def _parse_headers(raw: bytes) -> dict:
380
+ headers: dict[str, list[str]] = {}
381
+ for line in raw.split(b"\r\n"):
382
+ if b":" not in line:
383
+ continue
384
+ k, _, v = line.partition(b":")
385
+ key = k.strip().decode(errors="replace").lower()
386
+ val = v.strip().decode(errors="replace")
387
+ headers.setdefault(key, []).append(val)
388
+ return headers
389
+
390
+
391
+ _STATUS = {
392
+ 200: "OK",
393
+ 201: "Created",
394
+ 204: "No Content",
395
+ 301: "Moved Permanently",
396
+ 302: "Found",
397
+ 304: "Not Modified",
398
+ 400: "Bad Request",
399
+ 401: "Unauthorized",
400
+ 403: "Forbidden",
401
+ 404: "Not Found",
402
+ 405: "Method Not Allowed",
403
+ 429: "Too Many Requests",
404
+ 500: "Internal Server Error",
405
+ 502: "Bad Gateway",
406
+ 503: "Service Unavailable",
407
+ }
File without changes
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import time
6
+ from dataclasses import dataclass, field
7
+
8
+ import httpx
9
+
10
+ from pypproxy.store.models import Entry
11
+
12
+
13
+ @dataclass
14
+ class ReplayOptions:
15
+ override_host: str = ""
16
+ extra_headers: dict[str, str] = field(default_factory=dict)
17
+ timeout_seconds: int = 30
18
+ count: int = 1
19
+
20
+
21
+ @dataclass
22
+ class ReplayResult:
23
+ entry_id: int
24
+ status_code: int = 0
25
+ body: bytes = b""
26
+ duration_ms: int = 0
27
+ error: str = ""
28
+
29
+ def to_dict(self) -> dict:
30
+ return {
31
+ "entry_id": self.entry_id,
32
+ "status_code": self.status_code,
33
+ "body": base64.b64encode(self.body).decode() if self.body else "",
34
+ "duration_ms": self.duration_ms,
35
+ "error": self.error,
36
+ }
37
+
38
+
39
+ async def replay_one(entry: Entry, opts: ReplayOptions) -> ReplayResult:
40
+ host = opts.override_host or entry.host
41
+ url = f"{entry.scheme}://{host}{entry.path}"
42
+ if entry.query:
43
+ url += f"?{entry.query}"
44
+
45
+ headers = {}
46
+ for k, vs in entry.req_headers.items():
47
+ headers[k] = ", ".join(vs)
48
+ headers.update(opts.extra_headers)
49
+
50
+ start = time.monotonic()
51
+ try:
52
+ async with httpx.AsyncClient(
53
+ timeout=opts.timeout_seconds,
54
+ verify=False,
55
+ ) as client:
56
+ resp = await client.request(
57
+ method=entry.method,
58
+ url=url,
59
+ headers=headers,
60
+ content=entry.req_body,
61
+ )
62
+ dur = int((time.monotonic() - start) * 1000)
63
+ return ReplayResult(
64
+ entry_id=entry.id,
65
+ status_code=resp.status_code,
66
+ body=resp.content,
67
+ duration_ms=dur,
68
+ )
69
+ except Exception as e:
70
+ dur = int((time.monotonic() - start) * 1000)
71
+ return ReplayResult(entry_id=entry.id, duration_ms=dur, error=str(e))
72
+
73
+
74
+ async def replay_many(entry: Entry, opts: ReplayOptions) -> list[ReplayResult]:
75
+ count = max(1, opts.count)
76
+ tasks = [replay_one(entry, opts) for _ in range(count)]
77
+ return await asyncio.gather(*tasks)
File without changes