goodput-http 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.
goodput/classify.py ADDED
@@ -0,0 +1,191 @@
1
+ """Classification of attempts into success / throttle / error (brief §14).
2
+
3
+ Classification is fully pluggable. The engine runs, in order:
4
+
5
+ 1. a :class:`ThrottleClassifier` (does this attempt evidence throttling?),
6
+ 2. a :class:`SuccessClassifier` (is this a logical success?),
7
+ 3. an :class:`ErrorClassifier` (if not success, what kind of error, retryable?).
8
+
9
+ Default implementations cover the common cases (2xx success, 429/Retry-After
10
+ throttle, 5xx retryable). Users supply their own by implementing the protocols
11
+ or passing predicate callbacks to :class:`Classifiers.build`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable, Mapping, Sequence
17
+ from dataclasses import dataclass
18
+ from typing import Protocol
19
+
20
+ from .models import Classification, ErrorClass, Outcome, ThrottleSignal
21
+ from .retry_after import parse_rate_limit_headers, parse_retry_after
22
+
23
+
24
+ @dataclass
25
+ class ResponseView:
26
+ """The minimal, transport-agnostic view classifiers operate on.
27
+
28
+ Keeping this separate from httpx's ``Response`` lets simulations and tests
29
+ classify without a real HTTP layer, and keeps body access bounded.
30
+ """
31
+
32
+ status_code: int | None
33
+ headers: Mapping[str, str]
34
+ elapsed: float
35
+ body_preview: bytes = b""
36
+ error_class: ErrorClass = ErrorClass.NONE
37
+ error_message: str | None = None
38
+ now_epoch: float | None = None
39
+
40
+
41
+ # A user predicate may be sync or async and returns a bool or a Classification.
42
+ SyncPredicate = Callable[[ResponseView], bool]
43
+ ThrottlePredicate = Callable[[ResponseView], ThrottleSignal | None]
44
+
45
+
46
+ class SuccessClassifier(Protocol):
47
+ def __call__(self, view: ResponseView) -> bool: ...
48
+
49
+
50
+ class ThrottleClassifier(Protocol):
51
+ def __call__(self, view: ResponseView) -> ThrottleSignal | None: ...
52
+
53
+
54
+ class ErrorClassifier(Protocol):
55
+ def __call__(self, view: ResponseView) -> tuple[ErrorClass, bool]:
56
+ """Return ``(error_class, retryable)``."""
57
+ ...
58
+
59
+
60
+ # -- defaults ------------------------------------------------------------------
61
+
62
+
63
+ def default_success(view: ResponseView, *, ok_statuses: range = range(200, 300)) -> bool:
64
+ return view.status_code is not None and view.status_code in ok_statuses
65
+
66
+
67
+ def default_throttle(
68
+ view: ResponseView,
69
+ *,
70
+ throttle_statuses: frozenset[int] = frozenset({429}),
71
+ ) -> ThrottleSignal | None:
72
+ """Detect throttling from status + Retry-After + rate-limit headers."""
73
+ evidence: list[str] = []
74
+ throttled = False
75
+ retry_after: float | None = None
76
+ reset_at: float | None = None
77
+
78
+ if view.status_code in throttle_statuses:
79
+ throttled = True
80
+ evidence.append(f"status={view.status_code}")
81
+
82
+ ra = parse_retry_after(_get(view.headers, "retry-after"), now_epoch=view.now_epoch)
83
+ if ra is not None:
84
+ retry_after = ra
85
+ evidence.append(f"retry-after={ra:g}s")
86
+ # Retry-After on a 503 is a strong throttle/overload signal too.
87
+ if view.status_code == 503:
88
+ throttled = True
89
+
90
+ rl = parse_rate_limit_headers(view.headers, now_epoch=view.now_epoch)
91
+ if rl is not None and rl.exhausted:
92
+ throttled = True
93
+ reset_at = rl.reset_seconds
94
+ evidence.append(f"ratelimit-remaining=0 ({rl.source})")
95
+
96
+ if not throttled:
97
+ return None
98
+ return ThrottleSignal(
99
+ throttled=True,
100
+ confidence=1.0 if view.status_code in throttle_statuses else 0.7,
101
+ retry_after=retry_after,
102
+ reset_at=reset_at,
103
+ evidence=tuple(evidence),
104
+ )
105
+
106
+
107
+ def default_error(view: ResponseView) -> tuple[ErrorClass, bool]:
108
+ """Classify a non-success, non-throttle attempt and whether it is retryable."""
109
+ if view.error_class is not ErrorClass.NONE:
110
+ # Transport-level error already classified upstream.
111
+ retryable = view.error_class in {
112
+ ErrorClass.DNS,
113
+ ErrorClass.CONNECT,
114
+ ErrorClass.TLS,
115
+ ErrorClass.PROXY,
116
+ ErrorClass.TIMEOUT,
117
+ ErrorClass.READ,
118
+ }
119
+ return view.error_class, retryable
120
+ sc = view.status_code
121
+ if sc is None:
122
+ return ErrorClass.UNKNOWN, True
123
+ if sc in (401, 403):
124
+ return ErrorClass.AUTH, False
125
+ if 500 <= sc < 600:
126
+ return ErrorClass.HTTP_STATUS, True # transient server error → retryable
127
+ if 400 <= sc < 500:
128
+ return ErrorClass.HTTP_STATUS, False # client error → not retryable
129
+ return ErrorClass.HTTP_STATUS, False
130
+
131
+
132
+ def _get(headers: Mapping[str, str], key: str) -> str | None:
133
+ key = key.lower()
134
+ for k, v in headers.items():
135
+ if k.lower() == key:
136
+ return v
137
+ return None
138
+
139
+
140
+ @dataclass
141
+ class Classifiers:
142
+ """Bundle of the three classifier stages used by the engine."""
143
+
144
+ success: SuccessClassifier
145
+ throttle: ThrottleClassifier
146
+ error: ErrorClassifier
147
+
148
+ @classmethod
149
+ def default(cls) -> Classifiers:
150
+ return cls(success=default_success, throttle=default_throttle, error=default_error)
151
+
152
+ @classmethod
153
+ def build(
154
+ cls,
155
+ *,
156
+ ok_statuses: Sequence[int] | None = None,
157
+ success: SuccessClassifier | None = None,
158
+ throttle: ThrottleClassifier | None = None,
159
+ error: ErrorClassifier | None = None,
160
+ ) -> Classifiers:
161
+ """Build classifiers from common overrides without writing classes."""
162
+ succ = success
163
+ if succ is None and ok_statuses is not None:
164
+ ok = frozenset(ok_statuses)
165
+ succ = lambda v: v.status_code is not None and v.status_code in ok # noqa: E731
166
+ return cls(
167
+ success=succ or default_success,
168
+ throttle=throttle or default_throttle,
169
+ error=error or default_error,
170
+ )
171
+
172
+ def classify(self, view: ResponseView) -> Classification:
173
+ """Run all stages and produce a single :class:`Classification`."""
174
+ throttle = self.throttle(view)
175
+ if throttle is not None and throttle.throttled:
176
+ return Classification(
177
+ outcome=Outcome.THROTTLED,
178
+ error_class=ErrorClass.THROTTLE,
179
+ retryable=True,
180
+ throttle=throttle,
181
+ reason="throttle classifier matched",
182
+ )
183
+ if self.success(view):
184
+ return Classification(outcome=Outcome.SUCCESS, reason="success classifier matched")
185
+ error_class, retryable = self.error(view)
186
+ return Classification(
187
+ outcome=Outcome.FAILURE,
188
+ error_class=error_class,
189
+ retryable=retryable,
190
+ reason=f"error classifier: {error_class.value} (retryable={retryable})",
191
+ )
goodput/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ """Command-line interface (brief §33).
2
+
3
+ Implemented with the standard library ``argparse`` so the CLI works without any
4
+ optional dependency. Commands:
5
+
6
+ * ``validate`` — validate a config file / inline options and print the dry-run plan.
7
+ * ``run`` — execute a repeated-endpoint or JSONL workload.
8
+ * ``plugins`` — list discovered plugins.
9
+ * ``version`` — print the version.
10
+
11
+ Secrets are never echoed; the CLI discourages putting secrets on the command
12
+ line (brief §33) and supports machine-readable JSON output and no-color mode.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+
23
+ from . import __version__
24
+ from .api import run as _run
25
+ from .config import EngineConfig, Mode, load_config
26
+ from .control_mode import bounded_adaptive, fixed
27
+ from .models import Request
28
+ from .plugins import PluginRegistry
29
+ from .sources import from_jsonl, repeat
30
+
31
+
32
+ def _build_config(args: argparse.Namespace) -> EngineConfig:
33
+ overrides: dict[str, object] = {}
34
+ if args.mode:
35
+ overrides["mode"] = Mode(args.mode)
36
+ if args.timeout:
37
+ overrides["default_timeout"] = args.timeout
38
+ cfg = (
39
+ load_config(Path(args.config), overrides=overrides)
40
+ if args.config
41
+ else EngineConfig(**overrides)
42
+ )
43
+ # Concurrency from CLI (explicit override beats file/defaults — brief §34).
44
+ if args.concurrency is not None:
45
+ if args.adaptive:
46
+ cfg.global_concurrency = bounded_adaptive(
47
+ minimum=1, maximum=args.max_concurrency or args.concurrency * 10,
48
+ initial=args.concurrency, name="global_concurrency",
49
+ )
50
+ else:
51
+ cfg.global_concurrency = fixed(args.concurrency, name="global_concurrency")
52
+ if args.rate is not None:
53
+ cfg.request_rate = fixed(float(args.rate), name="request_rate")
54
+ return cfg
55
+
56
+
57
+ def _cmd_validate(args: argparse.Namespace) -> int:
58
+ cfg = _build_config(args)
59
+ plan = cfg.validate_constraints()
60
+ if args.json:
61
+ print(json.dumps({
62
+ "ok": plan.ok,
63
+ "conflicts": plan.conflicts,
64
+ "warnings": plan.warnings,
65
+ }, indent=2))
66
+ else:
67
+ print(plan.render())
68
+ return 0 if plan.ok else 1
69
+
70
+
71
+ def _cmd_run(args: argparse.Namespace) -> int:
72
+ import anyio
73
+
74
+ cfg = _build_config(args)
75
+ plan = cfg.validate_constraints()
76
+ if not plan.ok:
77
+ print("configuration is invalid:", file=sys.stderr)
78
+ print(plan.render(), file=sys.stderr)
79
+ return 1
80
+
81
+ if args.jsonl:
82
+ source = from_jsonl(args.jsonl)
83
+ elif args.url:
84
+ source = repeat(Request(args.method, args.url), times=args.count)
85
+ else:
86
+ print("error: provide --url or --jsonl", file=sys.stderr)
87
+ return 2
88
+
89
+ sinks = None
90
+ if args.output:
91
+ from .sinks.jsonl import JSONLSink
92
+
93
+ sinks = [JSONLSink(args.output)]
94
+
95
+ async def _go(): # type: ignore[no-untyped-def]
96
+ return await _run(source, config=cfg, sinks=sinks)
97
+
98
+ result = anyio.run(_go)
99
+ if args.json:
100
+ print(json.dumps(result.report.to_dict(), indent=2))
101
+ else:
102
+ print(result.report.summary())
103
+ return 0
104
+
105
+
106
+ def _cmd_plugins(args: argparse.Namespace) -> int:
107
+ registry = PluginRegistry()
108
+ found = registry.discover_all()
109
+ if args.json:
110
+ print(json.dumps(found, indent=2))
111
+ else:
112
+ for group, names in found.items():
113
+ print(f"{group}:")
114
+ for name in names:
115
+ print(f" - {name}")
116
+ if not names:
117
+ print(" (none)")
118
+ return 0
119
+
120
+
121
+ def _cmd_version(args: argparse.Namespace) -> int:
122
+ print(f"goodput {__version__}")
123
+ return 0
124
+
125
+
126
+ def build_parser() -> argparse.ArgumentParser:
127
+ parser = argparse.ArgumentParser(
128
+ prog="goodput",
129
+ description="Adaptive HTTP execution engine that maximizes successful goodput.",
130
+ )
131
+ parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
132
+ sub = parser.add_subparsers(dest="command", required=True)
133
+
134
+ def add_common(p: argparse.ArgumentParser) -> None:
135
+ p.add_argument("--config", help="path to a TOML/JSON config file")
136
+ p.add_argument("--concurrency", type=int, help="global concurrency")
137
+ p.add_argument("--max-concurrency", type=int, help="upper bound for adaptive concurrency")
138
+ p.add_argument("--adaptive", action="store_true", help="make concurrency bounded-adaptive")
139
+ p.add_argument("--rate", type=float, help="fixed request-start rate (req/s)")
140
+ p.add_argument("--mode", choices=[m.value for m in Mode], help="operating mode")
141
+ p.add_argument("--timeout", type=float, help="default per-request timeout (s)")
142
+ p.add_argument("--json", action="store_true", help="machine-readable JSON output")
143
+
144
+ p_val = sub.add_parser("validate", help="validate config and print the plan")
145
+ add_common(p_val)
146
+ p_val.set_defaults(func=_cmd_validate)
147
+
148
+ p_run = sub.add_parser("run", help="execute a workload")
149
+ add_common(p_run)
150
+ p_run.add_argument("--url", help="endpoint to call repeatedly")
151
+ p_run.add_argument("--method", default="GET", help="HTTP method for --url")
152
+ p_run.add_argument("--count", type=int, default=100, help="number of requests for --url")
153
+ p_run.add_argument("--jsonl", help="JSONL workload file")
154
+ p_run.add_argument("--output", help="write per-result JSONL to this path")
155
+ p_run.set_defaults(func=_cmd_run)
156
+
157
+ p_plug = sub.add_parser("plugins", help="list discovered plugins")
158
+ p_plug.add_argument("--json", action="store_true")
159
+ p_plug.set_defaults(func=_cmd_plugins)
160
+
161
+ p_ver = sub.add_parser("version", help="print version")
162
+ p_ver.set_defaults(func=_cmd_version)
163
+
164
+ return parser
165
+
166
+
167
+ def main(argv: Sequence[str] | None = None) -> int:
168
+ parser = build_parser()
169
+ args = parser.parse_args(argv)
170
+ try:
171
+ return int(args.func(args))
172
+ except KeyboardInterrupt: # graceful Ctrl+C (brief §33)
173
+ print("\ninterrupted", file=sys.stderr)
174
+ return 130
175
+
176
+
177
+ if __name__ == "__main__": # pragma: no cover
178
+ raise SystemExit(main())
goodput/clock.py ADDED
@@ -0,0 +1,61 @@
1
+ """Clock abstraction (brief §4: monotonic clocks; §36: deterministic fake clocks).
2
+
3
+ Durations are always measured with a *monotonic* clock. Tests and controller
4
+ simulations inject a :class:`ManualClock` for determinism — no test ever sleeps
5
+ in wall-clock time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Protocol
12
+
13
+ import anyio
14
+
15
+
16
+ class Clock(Protocol):
17
+ """A source of monotonic time and async sleeping."""
18
+
19
+ def now(self) -> float:
20
+ """Monotonic seconds. Only differences are meaningful."""
21
+ ...
22
+
23
+ async def sleep(self, seconds: float) -> None:
24
+ """Sleep for ``seconds`` (cancellation-safe)."""
25
+ ...
26
+
27
+
28
+ class MonotonicClock:
29
+ """The production clock: :func:`time.monotonic` + :func:`anyio.sleep`."""
30
+
31
+ def now(self) -> float:
32
+ return time.monotonic()
33
+
34
+ async def sleep(self, seconds: float) -> None:
35
+ if seconds > 0:
36
+ await anyio.sleep(seconds)
37
+
38
+
39
+ class ManualClock:
40
+ """A deterministic clock for tests and simulations.
41
+
42
+ Time advances only when :meth:`advance` is called. ``sleep`` does not block
43
+ on wall time; it registers a wakeup and yields control so other tasks run.
44
+ For most unit tests, advancing time explicitly between assertions is enough.
45
+ """
46
+
47
+ def __init__(self, start: float = 0.0) -> None:
48
+ self._t = start
49
+
50
+ def now(self) -> float:
51
+ return self._t
52
+
53
+ def advance(self, seconds: float) -> None:
54
+ if seconds < 0:
55
+ raise ValueError("cannot move ManualClock backwards")
56
+ self._t += seconds
57
+
58
+ async def sleep(self, seconds: float) -> None:
59
+ # Advance virtual time and yield so cooperating tasks make progress.
60
+ self.advance(max(0.0, seconds))
61
+ await anyio.lowlevel.checkpoint()