jac-loadtest 0.2.1__tar.gz → 0.2.2__tar.gz

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 (23) hide show
  1. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/PKG-INFO +1 -1
  2. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/bridge/topology.py +3 -1
  3. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/config.py +4 -3
  4. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/engine.py +24 -8
  5. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/har_parser.py +55 -9
  6. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/metrics.py +8 -2
  7. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/PKG-INFO +1 -1
  8. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/pyproject.toml +1 -1
  9. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/README.md +0 -0
  10. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/__init__.py +0 -0
  11. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/bridge/__init__.py +0 -0
  12. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/bridge/auth.py +0 -0
  13. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/cli.py +0 -0
  14. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/__init__.py +0 -0
  15. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/output/__init__.py +0 -0
  16. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/output/reporter.py +0 -0
  17. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/plugin.py +0 -0
  18. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/SOURCES.txt +0 -0
  19. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/dependency_links.txt +0 -0
  20. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/entry_points.txt +0 -0
  21. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/requires.txt +0 -0
  22. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/top_level.txt +0 -0
  23. {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jac-loadtest
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: HAR-based load testing CLI for jac-scale applications
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -104,6 +104,8 @@ class TopologyRouter:
104
104
  def _build_microservice(cls, config: LoadTestConfig) -> TopologyRouter:
105
105
  toml_routes = _load_toml_routes() # service_name → prefix; {} if unavailable
106
106
 
107
+ routes: list[ServiceRoute]
108
+
107
109
  if config.services_map:
108
110
  try:
109
111
  services_json: dict[str, str] = json.loads(config.services_map)
@@ -129,7 +131,7 @@ class TopologyRouter:
129
131
  "[plugins.scale.microservices.routes] in jac.toml. Neither was found."
130
132
  )
131
133
 
132
- routes: list[ServiceRoute] = []
134
+ routes = []
133
135
  missing: list[str] = []
134
136
  for name, prefix in toml_routes.items():
135
137
  env_var = f"JAC_SV_{name.upper()}_URL"
@@ -5,12 +5,13 @@ Phase 2 will add jac.toml reading via jac_scale.config_loader.
5
5
  """
6
6
  from __future__ import annotations
7
7
  from dataclasses import dataclass, field
8
+ from typing import Any
8
9
 
9
10
 
10
11
  BUILT_IN_DEFAULTS: dict = {
11
12
  "vus": 1,
12
13
  "duration": "30s",
13
- "iterations": None,
14
+ "iterations": 1,
14
15
  "ramp_up": "0s",
15
16
  "timeout": "30s",
16
17
  "mode": "monolith",
@@ -36,7 +37,7 @@ class LoadTestConfig:
36
37
  # Load shape
37
38
  vus: int = 1
38
39
  duration: str = "30s"
39
- iterations: int | None = None
40
+ iterations: int = 1
40
41
  ramp_up: str = "0s"
41
42
  timeout: str = "30s"
42
43
 
@@ -107,7 +108,7 @@ def from_args(args: object) -> LoadTestConfig:
107
108
 
108
109
  # For toml-sourced fields, use CLI value if provided (not None), else toml value,
109
110
  # else built-in default.
110
- def resolve(name: str) -> object:
111
+ def resolve(name: str) -> Any:
111
112
  cli_val = getattr(args, name, None)
112
113
  if cli_val is not None:
113
114
  return cli_val
@@ -18,14 +18,16 @@ if TYPE_CHECKING:
18
18
  from jac_loadtest.core.har_parser import HarEntry
19
19
  from jac_loadtest.core.metrics import MetricsCollector
20
20
  from jac_loadtest.config import LoadTestConfig
21
+ from jac_loadtest.bridge.topology import TopologyRouter
22
+ from jac_loadtest.bridge.auth import AuthProvider
21
23
 
22
24
 
23
25
  async def run_all_vus(
24
26
  entries: list[HarEntry],
25
27
  config: LoadTestConfig,
26
28
  metrics: MetricsCollector,
27
- topology: object | None = None,
28
- auth_provider: object | None = None,
29
+ topology: TopologyRouter | None = None,
30
+ auth_provider: AuthProvider | None = None,
29
31
  ) -> None:
30
32
  """Spawn N virtual user coroutines and run until duration/iterations/stop signal."""
31
33
  stop_requested = asyncio.Event()
@@ -44,6 +46,9 @@ async def run_all_vus(
44
46
 
45
47
  ramp_up_seconds = parse_duration(config.ramp_up)
46
48
 
49
+ # Limit concurrent logins to avoid exhausting OS sockets when VU count is large.
50
+ auth_semaphore = asyncio.Semaphore(50)
51
+
47
52
  tasks = [
48
53
  asyncio.create_task(
49
54
  _run_vu(
@@ -56,6 +61,7 @@ async def run_all_vus(
56
61
  loop=loop,
57
62
  auth_provider=auth_provider,
58
63
  topology=topology,
64
+ auth_semaphore=auth_semaphore,
59
65
  )
60
66
  )
61
67
  for i in range(config.vus)
@@ -75,8 +81,9 @@ async def _run_vu(
75
81
  metrics: MetricsCollector,
76
82
  stop_requested: asyncio.Event,
77
83
  loop: asyncio.AbstractEventLoop,
78
- auth_provider: object | None = None,
79
- topology: object | None = None,
84
+ auth_provider: AuthProvider | None = None,
85
+ topology: TopologyRouter | None = None,
86
+ auth_semaphore: asyncio.Semaphore | None = None,
80
87
  ) -> None:
81
88
  """Single virtual user: wait ramp delay, authenticate, then replay HAR entries."""
82
89
  if delay > 0:
@@ -93,7 +100,10 @@ async def _run_vu(
93
100
  # Authenticate once before entering the request loop.
94
101
  token: str | None = None
95
102
  if auth_provider is not None:
96
- token = await auth_provider.authenticate(vu_id, session, config.url)
103
+ if not config.url:
104
+ raise ValueError("auth_provider requires --url to be set")
105
+ async with (auth_semaphore or asyncio.Semaphore()):
106
+ token = await auth_provider.authenticate(vu_id, session, config.url)
97
107
 
98
108
  while not stop_requested.is_set():
99
109
  if loop.time() - t_start >= duration_seconds:
@@ -123,8 +133,8 @@ async def _run_vu(
123
133
  path = _up(entry.url).path
124
134
  if path not in _warned_unrouted:
125
135
  print(
126
- f"Warning: no route for '{path}' — skipping. "
127
- "Add a matching prefix to --services-map or set --url as fallback.",
136
+ f"\n\033[33mWarning: no route for '{path}' — skipping. "
137
+ "Add a matching prefix to --services-map or set --url as fallback.\033[0m",
128
138
  file=sys.stderr,
129
139
  )
130
140
  _warned_unrouted.add(path)
@@ -145,7 +155,7 @@ async def _send_request(
145
155
  config: LoadTestConfig,
146
156
  loop: asyncio.AbstractEventLoop,
147
157
  token: str | None = None,
148
- topology: object | None = None,
158
+ topology: TopologyRouter | None = None,
149
159
  ) -> RequestResult | None:
150
160
  """Send one HTTP request and return a RequestResult, or None if no route exists."""
151
161
  headers = dict(entry.headers)
@@ -185,6 +195,8 @@ async def _send_request(
185
195
  timestamp=t0,
186
196
  vu_id=vu_id,
187
197
  error_type=None,
198
+ occurrence=entry.occurrence,
199
+ total_occurrences=entry.total_occurrences,
188
200
  )
189
201
 
190
202
  except asyncio.TimeoutError:
@@ -197,6 +209,8 @@ async def _send_request(
197
209
  timestamp=t0,
198
210
  vu_id=vu_id,
199
211
  error_type="TIMEOUT",
212
+ occurrence=entry.occurrence,
213
+ total_occurrences=entry.total_occurrences,
200
214
  )
201
215
 
202
216
  except aiohttp.ClientConnectorError:
@@ -209,4 +223,6 @@ async def _send_request(
209
223
  timestamp=t0,
210
224
  vu_id=vu_id,
211
225
  error_type="CONNECTION_REFUSED",
226
+ occurrence=entry.occurrence,
227
+ total_occurrences=entry.total_occurrences,
212
228
  )
@@ -49,6 +49,8 @@ class HarEntry:
49
49
  think_time_ms: float
50
50
  is_login: bool
51
51
  original_url: str
52
+ occurrence: int = 0
53
+ total_occurrences: int = 0
52
54
 
53
55
 
54
56
  def _origin(url: str) -> str:
@@ -81,6 +83,22 @@ def _is_unsupported_type(entry: dict) -> bool:
81
83
  return url.startswith(("ws://", "wss://"))
82
84
 
83
85
 
86
+ def _has_missing_body(method: str, raw_headers: list[dict], post_data: dict | None) -> bool:
87
+ """Return True if a request should have a body but none was captured in the HAR."""
88
+ if method.upper() not in ("POST", "PUT", "PATCH"):
89
+ return False
90
+ content_length = next(
91
+ (h["value"] for h in raw_headers if h.get("name", "").lower() == "content-length"),
92
+ "0",
93
+ )
94
+ try:
95
+ has_content = int(content_length) > 0
96
+ except ValueError:
97
+ has_content = False
98
+ body_text = (post_data or {}).get("text")
99
+ return has_content and not body_text
100
+
101
+
84
102
  def _has_cache_buster(url: str) -> bool:
85
103
  """Return True if the URL contains a stale cache-busting timestamp parameter."""
86
104
  qs = parse_qs(urlparse(url).query)
@@ -125,6 +143,7 @@ def parse_har(
125
143
  result: list[HarEntry] = []
126
144
  warned_unsupported = False
127
145
  warned_cache_buster = False
146
+ warned_missing_body = False
128
147
 
129
148
  for entry in raw_entries:
130
149
  req = entry["request"]
@@ -135,9 +154,9 @@ def parse_har(
135
154
  if _is_unsupported_type(entry):
136
155
  if not warned_unsupported:
137
156
  print(
138
- "Warning: HAR contains WebSocket, SSE, or non-API entries "
157
+ "\n\033[33mWarning: HAR contains WebSocket, SSE, or non-API entries "
139
158
  "(websocket, eventsource, document, etc.). "
140
- "These are skipped automatically.",
159
+ "These are skipped automatically.\033[0m",
141
160
  file=sys.stderr,
142
161
  )
143
162
  warned_unsupported = True
@@ -148,9 +167,9 @@ def parse_har(
148
167
  if _has_cache_buster(original_url):
149
168
  if not warned_cache_buster:
150
169
  print(
151
- "Warning: HAR contains URLs with cache-busting timestamp "
170
+ "\n\033[33mWarning: HAR contains URLs with cache-busting timestamp "
152
171
  "parameters (e.g. ?_=<timestamp>). These entries are skipped — "
153
- "the stale timestamp causes the server to reject the request.",
172
+ "the stale timestamp causes the server to reject the request.\033[0m",
154
173
  file=sys.stderr,
155
174
  )
156
175
  warned_cache_buster = True
@@ -164,7 +183,21 @@ def parse_har(
164
183
  else:
165
184
  rewritten_url = original_url # keep recorded URL; topology handles routing
166
185
 
167
- headers = _sanitize_headers(req.get("headers", []))
186
+ raw_headers = req.get("headers", [])
187
+
188
+ if _has_missing_body(req["method"], raw_headers, req.get("postData")):
189
+ if not warned_missing_body:
190
+ print(
191
+ "\n\033[33mWarning: HAR contains POST/PUT/PATCH entries where the request body "
192
+ "was not captured (postData missing despite non-zero Content-Length). "
193
+ "These entries are skipped — replaying them without a body would cause "
194
+ "422 errors. Re-record the HAR to capture the full request body.\033[0m",
195
+ file=sys.stderr,
196
+ )
197
+ warned_missing_body = True
198
+ continue
199
+
200
+ headers = _sanitize_headers(raw_headers)
168
201
 
169
202
  post_data = req.get("postData", {}) or {}
170
203
  body = post_data.get("text") or None
@@ -188,6 +221,19 @@ def parse_har(
188
221
  )
189
222
  )
190
223
 
224
+ # Count how many times each path appears so occurrence numbers can be assigned.
225
+ totals: dict[str, int] = {}
226
+ for entry in result:
227
+ path = urlparse(entry.url).path
228
+ totals[path] = totals.get(path, 0) + 1
229
+
230
+ seen: dict[str, int] = {}
231
+ for entry in result:
232
+ path = urlparse(entry.url).path
233
+ seen[path] = seen.get(path, 0) + 1
234
+ entry.occurrence = seen[path]
235
+ entry.total_occurrences = totals[path]
236
+
191
237
  return result
192
238
 
193
239
 
@@ -198,10 +244,10 @@ def _check_version(version: str) -> None:
198
244
  """Warn if the HAR version is outside the tested range."""
199
245
  if version not in _SUPPORTED_HAR_VERSIONS:
200
246
  print(
201
- f"Warning: HAR version '{version}' is not tested with this tool "
247
+ f"\n\033[33mWarning: HAR version '{version}' is not tested with this tool "
202
248
  f"(tested: {', '.join(sorted(_SUPPORTED_HAR_VERSIONS))}).\n"
203
249
  "Parsing will continue but results may be incomplete or incorrect.\n"
204
- "If the output looks wrong, check for a jac-loadtest update.",
250
+ "If the output looks wrong, check for a jac-loadtest update.\033[0m",
205
251
  file=sys.stderr,
206
252
  )
207
253
 
@@ -214,10 +260,10 @@ def _security_scan(entries: list[dict]) -> None:
214
260
  value = hdr.get("value", "")
215
261
  if name in ("authorization", "cookie") and value:
216
262
  print(
217
- "Warning: HAR file contains Authorization/Cookie headers from the "
263
+ "\n\033[33mWarning: HAR file contains Authorization/Cookie headers from the "
218
264
  "recording session.\nThese headers are stripped before replay, but "
219
265
  "the file itself contains sensitive data.\n"
220
- "Do not commit this HAR file to version control.",
266
+ "Do not commit this HAR file to version control.\033[0m",
221
267
  file=sys.stderr,
222
268
  )
223
269
  return
@@ -23,6 +23,8 @@ class RequestResult:
23
23
  timestamp: float
24
24
  vu_id: int
25
25
  error_type: str | None # None | "TIMEOUT" | "CONNECTION_REFUSED" | "DNS_ERROR" | "SSL_ERROR"
26
+ occurrence: int = 1
27
+ total_occurrences: int = 1
26
28
 
27
29
 
28
30
  @dataclass
@@ -113,11 +115,15 @@ class MetricsCollector:
113
115
  error_breakdown: dict[str, int] = {}
114
116
  for r in results:
115
117
  if r.error_type is not None:
116
- key = r.error_type
118
+ label = r.error_type
117
119
  elif not (200 <= r.status < 300):
118
- key = str(r.status)
120
+ label = str(r.status)
119
121
  else:
120
122
  continue
123
+ if r.total_occurrences > 1:
124
+ key = f"{label} (call #{r.occurrence} of {r.total_occurrences})"
125
+ else:
126
+ key = label
121
127
  error_breakdown[key] = error_breakdown.get(key, 0) + 1
122
128
 
123
129
  service = results[0].service if results else "monolith"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jac-loadtest
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: HAR-based load testing CLI for jac-scale applications
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jac-loadtest"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "HAR-based load testing CLI for jac-scale applications"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
File without changes
File without changes