quorum-api 0.1.0__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.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: quorum-api
3
+ Version: 0.1.0
4
+ Summary: Typed Python client for the Quorum market evidence API. Errors are values, Retry-After is honoured, zero dependencies.
5
+ Author: Quorum
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Godzilla-lab/Quorum-API
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+
15
+ # quorum-api (Python)
16
+
17
+ The typed Python client for the Quorum market evidence API. Written against
18
+ `spec/openapi.yaml`, which is the contract. Standard library only: no
19
+ dependencies, and the transport is injectable so the tests run with no
20
+ network at all.
21
+
22
+ Errors are values, never raised. Retry-After is honoured, and
23
+ `wait_for_report` implements the polling loop correctly once so every caller
24
+ does not implement it wrongly.
25
+
26
+ ```python
27
+ from quorum_api import QuorumClient
28
+
29
+ client = QuorumClient("https://quorum-api-j15n.onrender.com", api_key="qk_...")
30
+
31
+ accepted = client.create_report("running shoes", terms=["sizing"])
32
+ if accepted.ok:
33
+ report = client.wait_for_report(accepted.data["id"])
34
+ if report.ok:
35
+ for finding in report.data["findings"]:
36
+ print(finding["term"], finding["records"], "records")
37
+
38
+ # Every receipt id resolves back to the real record behind it.
39
+ record = client.get_evidence("rc_4d6d444821b0044f")
40
+ ```
41
+
42
+ Not yet published to PyPI. Install from the repo:
43
+
44
+ ```bash
45
+ pip install packages/sdk-py
46
+ ```
47
+
48
+ Tests: `python3 -m unittest discover -s packages/sdk-py/tests`
@@ -0,0 +1,34 @@
1
+ # quorum-api (Python)
2
+
3
+ The typed Python client for the Quorum market evidence API. Written against
4
+ `spec/openapi.yaml`, which is the contract. Standard library only: no
5
+ dependencies, and the transport is injectable so the tests run with no
6
+ network at all.
7
+
8
+ Errors are values, never raised. Retry-After is honoured, and
9
+ `wait_for_report` implements the polling loop correctly once so every caller
10
+ does not implement it wrongly.
11
+
12
+ ```python
13
+ from quorum_api import QuorumClient
14
+
15
+ client = QuorumClient("https://quorum-api-j15n.onrender.com", api_key="qk_...")
16
+
17
+ accepted = client.create_report("running shoes", terms=["sizing"])
18
+ if accepted.ok:
19
+ report = client.wait_for_report(accepted.data["id"])
20
+ if report.ok:
21
+ for finding in report.data["findings"]:
22
+ print(finding["term"], finding["records"], "records")
23
+
24
+ # Every receipt id resolves back to the real record behind it.
25
+ record = client.get_evidence("rc_4d6d444821b0044f")
26
+ ```
27
+
28
+ Not yet published to PyPI. Install from the repo:
29
+
30
+ ```bash
31
+ pip install packages/sdk-py
32
+ ```
33
+
34
+ Tests: `python3 -m unittest discover -s packages/sdk-py/tests`
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "quorum-api"
7
+ version = "0.1.0"
8
+ description = "Typed Python client for the Quorum market evidence API. Errors are values, Retry-After is honoured, zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Quorum" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/Godzilla-lab/Quorum-API"
22
+
23
+ [tool.setuptools]
24
+ packages = ["quorum_api"]
@@ -0,0 +1,30 @@
1
+ """quorum-api. The typed Python client for the hosted API.
2
+
3
+ WRITTEN AGAINST spec/openapi.yaml, WHICH IS THE CONTRACT. Every method here is
4
+ one operationId from that file, and the spec wins when they disagree.
5
+
6
+ THE SAME TWO DECISIONS THE JAVASCRIPT SDK MADE, for the same reasons.
7
+
8
+ ERRORS ARE VALUES, NEVER RAISED. The house rule everywhere a vendor can be
9
+ down, and an SDK IS the place a vendor can be down. A caller gets a Result
10
+ whose error carries the server's type, message and requestId rather than an
11
+ exception, because the interesting failures here are a 429, a 503 with a
12
+ Retry-After, and a report that is simply not finished, and none of those are
13
+ exceptional.
14
+
15
+ IT HONOURS RETRY-AFTER. The server sheds under load with a 503 and rate
16
+ limits with a 429, both carrying the wait. wait_for_report implements the
17
+ polling loop correctly once, so every caller does not implement it wrongly.
18
+
19
+ ZERO DEPENDENCIES. The standard library's urllib is the transport, and the
20
+ transport is injectable so the tests run with no network at all.
21
+ """
22
+
23
+ from .client import (
24
+ ApiError,
25
+ QuorumClient,
26
+ Result,
27
+ )
28
+
29
+ __all__ = ["ApiError", "QuorumClient", "Result"]
30
+ __version__ = "0.1.0"
@@ -0,0 +1,314 @@
1
+ """The client. See the package docstring for the two decisions that shape it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
11
+ from urllib.parse import quote
12
+
13
+ # What a transport returns: status, lowercased headers, body bytes.
14
+ TransportResponse = Tuple[int, Dict[str, str], bytes]
15
+ # (method, url, headers, body bytes or None, timeout seconds) -> response.
16
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], TransportResponse]
17
+
18
+
19
+ @dataclass
20
+ class ApiError:
21
+ """The server's machine readable failure, or the absence of a server.
22
+
23
+ type is the server's class: rate_limited, not_found, queue_saturated,
24
+ unauthorized, bad_request, conflict; or this client's own two, network
25
+ and timeout, when the request never got an answer. status is the HTTP
26
+ status, or 0 when nothing answered at all, which is a different problem
27
+ from any status code and a caller should be able to tell them apart.
28
+ """
29
+
30
+ type: str
31
+ message: str
32
+ request_id: Optional[str] = None
33
+ # Present on 429 and 503. Seconds. Honour it.
34
+ retry_after_seconds: Optional[float] = None
35
+ status: int = 0
36
+
37
+
38
+ @dataclass
39
+ class Result:
40
+ """ok with data, or not ok with error. Never both, never neither."""
41
+
42
+ ok: bool
43
+ data: Any = None
44
+ error: Optional[ApiError] = None
45
+
46
+
47
+ def _failure(status: int, kind: str, message: str,
48
+ request_id: Optional[str] = None,
49
+ retry_after: Optional[float] = None) -> Result:
50
+ return Result(ok=False, error=ApiError(
51
+ type=kind, message=message, request_id=request_id,
52
+ retry_after_seconds=retry_after, status=status,
53
+ ))
54
+
55
+
56
+ def _default_transport(method: str, url: str, headers: Dict[str, str],
57
+ body: Optional[bytes], timeout: float) -> TransportResponse:
58
+ request = urllib.request.Request(url, data=body, method=method)
59
+ for name, value in headers.items():
60
+ request.add_header(name, value)
61
+ try:
62
+ with urllib.request.urlopen(request, timeout=timeout) as response:
63
+ return (response.status,
64
+ {k.lower(): v for k, v in response.headers.items()},
65
+ response.read())
66
+ except urllib.error.HTTPError as err:
67
+ # urllib raises on any non-2xx. The status and body are still the
68
+ # server speaking, so they travel back as a response, not an error.
69
+ return (err.code,
70
+ {k.lower(): v for k, v in (err.headers or {}).items()},
71
+ err.read())
72
+
73
+
74
+ class QuorumClient:
75
+ """One instance per base URL and key.
76
+
77
+ The base URL is supplied by the caller and never hardcoded. That is what
78
+ makes this a client for YOUR instance rather than a vendor client: the
79
+ address is configuration, hosted or self hosted alike.
80
+ """
81
+
82
+ def __init__(self, base_url: str, api_key: Optional[str] = None,
83
+ timeout_seconds: float = 30.0,
84
+ transport: Optional[Transport] = None) -> None:
85
+ self._base = base_url.rstrip("/") + "/v1"
86
+ # Trimmed for the reason learned three times on 2026-08-24: a pasted
87
+ # key carries a newline, and an untrimmed one dies at the header.
88
+ self._api_key = api_key.strip() if api_key else None
89
+ self._timeout = timeout_seconds
90
+ self._transport = transport or _default_transport
91
+
92
+ def _headers(self, has_body: bool) -> Dict[str, str]:
93
+ headers = {"accept": "application/json"}
94
+ if has_body:
95
+ headers["content-type"] = "application/json"
96
+ if self._api_key:
97
+ headers["authorization"] = "Bearer " + self._api_key
98
+ return headers
99
+
100
+ def _call(self, method: str, path: str, body: Any = None) -> Result:
101
+ payload = None if body is None else json.dumps(body).encode("utf-8")
102
+ try:
103
+ status, headers, raw = self._transport(
104
+ method, self._base + path, self._headers(payload is not None),
105
+ payload, self._timeout)
106
+ except Exception as cause: # noqa: BLE001 - errors are values here.
107
+ kind = "timeout" if "timed out" in str(cause).lower() else "network"
108
+ return _failure(0, kind, str(cause))
109
+
110
+ text = raw.decode("utf-8", errors="replace") if raw else ""
111
+ try:
112
+ parsed = json.loads(text) if text else None
113
+ except ValueError:
114
+ # A proxy returning HTML is the common case here, and a raw json
115
+ # parse error would send somebody debugging this client.
116
+ return _failure(status, "bad_response",
117
+ "the server returned %d with a body that is not json" % status)
118
+
119
+ if 200 <= status < 300:
120
+ return Result(ok=True, data=parsed)
121
+
122
+ err = (parsed or {}).get("error") if isinstance(parsed, dict) else None
123
+ err = err if isinstance(err, dict) else {}
124
+ # The header wins over the body: a proxy may add one the app did not.
125
+ header = headers.get("retry-after")
126
+ retry = None
127
+ if header is not None:
128
+ try:
129
+ retry = float(header)
130
+ except ValueError:
131
+ retry = None
132
+ if retry is None:
133
+ retry = err.get("retryAfterSeconds")
134
+ return _failure(status,
135
+ err.get("type") or "http_error",
136
+ err.get("message") or ("the server returned %d" % status),
137
+ err.get("requestId"),
138
+ retry)
139
+
140
+ # --- reports, the slow path ---
141
+
142
+ def create_report(self, subject: str, *, terms: Optional[List[str]] = None,
143
+ communities: Optional[List[str]] = None,
144
+ sources: Optional[List[str]] = None,
145
+ include_ads: Optional[bool] = None,
146
+ offline: Optional[bool] = None,
147
+ cap_usd: Optional[float] = None,
148
+ deadline_ms: Optional[int] = None,
149
+ webhook_url: Optional[str] = None) -> Result:
150
+ body: Dict[str, Any] = {"subject": subject}
151
+ for key, value in (("terms", terms), ("communities", communities),
152
+ ("sources", sources), ("includeAds", include_ads),
153
+ ("offline", offline), ("capUsd", cap_usd),
154
+ ("deadlineMs", deadline_ms), ("webhookUrl", webhook_url)):
155
+ if value is not None:
156
+ body[key] = value
157
+ return self._call("POST", "/reports", body)
158
+
159
+ def get_report(self, report_id: str) -> Result:
160
+ return self._call("GET", "/reports/" + quote(report_id, safe=""))
161
+
162
+ def cancel_report(self, report_id: str) -> Result:
163
+ return self._call("DELETE", "/reports/" + quote(report_id, safe=""))
164
+
165
+ # --- evidence, the fast path ---
166
+
167
+ def get_evidence(self, receipt_id: str) -> Result:
168
+ return self._call("GET", "/evidence/" + quote(receipt_id, safe=""))
169
+
170
+ def get_evidence_batch(self, receipt_ids: List[str]) -> Result:
171
+ return self._call("POST", "/evidence/batch", {"receiptIds": receipt_ids})
172
+
173
+ def search_evidence(self, query: str, *, category: Optional[str] = None,
174
+ limit: Optional[int] = None) -> Result:
175
+ body: Dict[str, Any] = {"query": query}
176
+ if category is not None:
177
+ body["category"] = category
178
+ if limit is not None:
179
+ body["limit"] = limit
180
+ return self._call("POST", "/evidence/search", body)
181
+
182
+ def get_ad_evidence(self, ad_id: str) -> Result:
183
+ return self._call("GET", "/evidence/ads/" + quote(ad_id, safe=""))
184
+
185
+ def get_category(self, slug: str) -> Result:
186
+ return self._call("GET", "/categories/" + quote(slug, safe=""))
187
+
188
+ def list_categories(self) -> Result:
189
+ return self._call("GET", "/categories")
190
+
191
+ # --- verification and account ---
192
+
193
+ def verify_claims(self, claims: List[Dict[str, Any]]) -> Result:
194
+ """Re-resolve every cited id against the corpus, ours or anybody's.
195
+
196
+ A claim citing an id that does not exist is reported rather than
197
+ quietly passed, which is the point of the whole product.
198
+ """
199
+ return self._call("POST", "/verify", {"claims": claims})
200
+
201
+ def get_usage(self) -> Result:
202
+ return self._call("GET", "/usage")
203
+
204
+ def healthz(self) -> Result:
205
+ return self._call("GET", "/healthz")
206
+
207
+ # --- the loop every caller would otherwise write badly ---
208
+
209
+ def wait_for_report(self, report_id: str, *,
210
+ timeout_seconds: float = 900.0,
211
+ poll_seconds: float = 2.0,
212
+ on_poll: Optional[Callable[[Any], None]] = None,
213
+ sleep: Callable[[float], None] = time.sleep,
214
+ clock: Callable[[], float] = time.monotonic) -> Result:
215
+ """Poll a report to completion, honouring the server's own pacing.
216
+
217
+ Three things this gets right that a naive loop does not: a 503 is the
218
+ load shedder speaking, not a failure; the server sets the pace through
219
+ Retry-After rather than the client guessing; and on timeout it returns
220
+ the last status it saw rather than pretending the report failed.
221
+ """
222
+ deadline = clock() + timeout_seconds
223
+ last_status: Optional[str] = None
224
+
225
+ while True:
226
+ result = self.get_report(report_id)
227
+
228
+ if result.ok:
229
+ data = result.data if isinstance(result.data, dict) else {}
230
+ last_status = data.get("status")
231
+ if on_poll is not None:
232
+ on_poll(result.data)
233
+ if last_status not in ("queued", "running"):
234
+ return result
235
+ suggested = None
236
+ elif result.error is not None and result.error.status in (429, 503):
237
+ suggested = result.error.retry_after_seconds
238
+ else:
239
+ # A real error. Anything else was the server asking us to wait.
240
+ return result
241
+
242
+ wait = max(poll_seconds, float(suggested or 0))
243
+ if clock() + wait > deadline:
244
+ message = ("gave up after the deadline with the report still %s" % last_status
245
+ if last_status else
246
+ "gave up after the deadline without reaching the server")
247
+ return _failure(0, "timeout", message)
248
+ sleep(wait)
249
+
250
+ def stream_report(self, report_id: str,
251
+ opener: Optional[Callable[[urllib.request.Request, float], Any]] = None,
252
+ ) -> Iterator[Dict[str, Any]]:
253
+ """Yield a running report's server sent events as dicts.
254
+
255
+ Parsed by hand for the same reason the JavaScript SDK parses by hand:
256
+ the frame is id, event and data lines terminated by a blank line, and
257
+ a chunk boundary can fall anywhere, so the buffer is drained on
258
+ complete frames only. A transport level failure ends the stream
259
+ rather than raising, because a dropped stream is a normal way for a
260
+ finished report to say goodbye.
261
+ """
262
+ request = urllib.request.Request(
263
+ self._base + "/reports/" + quote(report_id, safe="") + "/stream")
264
+ for name, value in self._headers(False).items():
265
+ request.add_header(name, "text/event-stream" if name == "accept" else value)
266
+
267
+ open_stream = opener or (lambda req, timeout: urllib.request.urlopen(req, timeout=timeout))
268
+ try:
269
+ response = open_stream(request, self._timeout)
270
+ except Exception: # noqa: BLE001 - a stream that never opened is empty.
271
+ return
272
+
273
+ buffer = ""
274
+ try:
275
+ while True:
276
+ chunk = response.read(1024)
277
+ if not chunk:
278
+ break
279
+ buffer += chunk.decode("utf-8", errors="replace")
280
+ while "\n\n" in buffer:
281
+ frame, buffer = buffer.split("\n\n", 1)
282
+ event = _parse_frame(frame)
283
+ if event is not None:
284
+ yield event
285
+ except Exception: # noqa: BLE001
286
+ return
287
+ finally:
288
+ close = getattr(response, "close", None)
289
+ if close is not None:
290
+ close()
291
+
292
+
293
+ def _parse_frame(frame: str) -> Optional[Dict[str, Any]]:
294
+ event_id = 0
295
+ kind = "message"
296
+ data: List[str] = []
297
+ for line in frame.split("\n"):
298
+ if line.startswith("id:"):
299
+ try:
300
+ event_id = int(line[3:].strip())
301
+ except ValueError:
302
+ event_id = 0
303
+ elif line.startswith("event:"):
304
+ kind = line[6:].strip()
305
+ elif line.startswith("data:"):
306
+ # Multiple data lines in one frame concatenate, per the SSE spec.
307
+ data.append(line[5:].strip())
308
+ if not data:
309
+ return None
310
+ joined = "\n".join(data)
311
+ try:
312
+ return {"id": event_id, "type": kind, "data": json.loads(joined)}
313
+ except ValueError:
314
+ return {"id": event_id, "type": kind, "data": joined}
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: quorum-api
3
+ Version: 0.1.0
4
+ Summary: Typed Python client for the Quorum market evidence API. Errors are values, Retry-After is honoured, zero dependencies.
5
+ Author: Quorum
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Godzilla-lab/Quorum-API
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+
15
+ # quorum-api (Python)
16
+
17
+ The typed Python client for the Quorum market evidence API. Written against
18
+ `spec/openapi.yaml`, which is the contract. Standard library only: no
19
+ dependencies, and the transport is injectable so the tests run with no
20
+ network at all.
21
+
22
+ Errors are values, never raised. Retry-After is honoured, and
23
+ `wait_for_report` implements the polling loop correctly once so every caller
24
+ does not implement it wrongly.
25
+
26
+ ```python
27
+ from quorum_api import QuorumClient
28
+
29
+ client = QuorumClient("https://quorum-api-j15n.onrender.com", api_key="qk_...")
30
+
31
+ accepted = client.create_report("running shoes", terms=["sizing"])
32
+ if accepted.ok:
33
+ report = client.wait_for_report(accepted.data["id"])
34
+ if report.ok:
35
+ for finding in report.data["findings"]:
36
+ print(finding["term"], finding["records"], "records")
37
+
38
+ # Every receipt id resolves back to the real record behind it.
39
+ record = client.get_evidence("rc_4d6d444821b0044f")
40
+ ```
41
+
42
+ Not yet published to PyPI. Install from the repo:
43
+
44
+ ```bash
45
+ pip install packages/sdk-py
46
+ ```
47
+
48
+ Tests: `python3 -m unittest discover -s packages/sdk-py/tests`
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ quorum_api/__init__.py
4
+ quorum_api/client.py
5
+ quorum_api.egg-info/PKG-INFO
6
+ quorum_api.egg-info/SOURCES.txt
7
+ quorum_api.egg-info/dependency_links.txt
8
+ quorum_api.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ quorum_api
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,179 @@
1
+ """The Python client, tested with no network at all.
2
+
3
+ Every test injects a transport, so the suite runs inside the same egress
4
+ blocked CI namespace as everything else. The behaviours under test are the
5
+ two decisions the client is built on: errors are values, and the server's
6
+ Retry-After sets the pace.
7
+ """
8
+
9
+ import io
10
+ import json
11
+ import unittest
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17
+
18
+ from quorum_api import ApiError, QuorumClient, Result # noqa: E402
19
+
20
+
21
+ def transport_returning(status, body, headers=None):
22
+ calls = []
23
+
24
+ def transport(method, url, request_headers, payload, timeout):
25
+ calls.append({
26
+ "method": method, "url": url, "headers": request_headers,
27
+ "body": payload, "timeout": timeout,
28
+ })
29
+ raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
30
+ return status, headers or {}, raw
31
+
32
+ transport.calls = calls
33
+ return transport
34
+
35
+
36
+ class CallShape(unittest.TestCase):
37
+ def test_paths_headers_and_encoding(self):
38
+ transport = transport_returning(200, {"ok": True})
39
+ client = QuorumClient("https://api.example/", api_key="qk_test\n",
40
+ transport=transport)
41
+ client.get_evidence("rc_abc/def")
42
+ call = transport.calls[0]
43
+ self.assertEqual(call["url"], "https://api.example/v1/evidence/rc_abc%2Fdef")
44
+ self.assertEqual(call["headers"]["authorization"], "Bearer qk_test",
45
+ "a pasted newline never reaches the header")
46
+ self.assertNotIn("content-type", call["headers"], "no body, no content type")
47
+
48
+ def test_create_report_sends_only_what_was_given(self):
49
+ transport = transport_returning(202, {"id": "rep_1", "status": "queued"})
50
+ client = QuorumClient("https://api.example", transport=transport)
51
+ result = client.create_report("running shoes", terms=["sizing"], offline=True)
52
+ self.assertTrue(result.ok)
53
+ sent = json.loads(transport.calls[0]["body"].decode("utf-8"))
54
+ self.assertEqual(sent, {"subject": "running shoes", "terms": ["sizing"],
55
+ "offline": True},
56
+ "absent options are absent, not null")
57
+
58
+ def test_keyless_sends_no_authorization(self):
59
+ transport = transport_returning(200, {"ok": True})
60
+ QuorumClient("https://api.example", transport=transport).healthz()
61
+ self.assertNotIn("authorization", transport.calls[0]["headers"])
62
+
63
+
64
+ class ErrorsAreValues(unittest.TestCase):
65
+ def test_server_error_body_travels_back(self):
66
+ transport = transport_returning(429, {"error": {
67
+ "type": "rate_limited", "message": "too many",
68
+ "requestId": "req-1", "retryAfterSeconds": 30,
69
+ }})
70
+ result = QuorumClient("https://api.example", transport=transport).get_usage()
71
+ self.assertFalse(result.ok)
72
+ self.assertEqual(result.error.type, "rate_limited")
73
+ self.assertEqual(result.error.status, 429)
74
+ self.assertEqual(result.error.request_id, "req-1")
75
+ self.assertEqual(result.error.retry_after_seconds, 30)
76
+
77
+ def test_retry_after_header_wins_over_the_body(self):
78
+ transport = transport_returning(
79
+ 503, {"error": {"type": "overloaded", "message": "shed",
80
+ "retryAfterSeconds": 99}},
81
+ headers={"retry-after": "7"})
82
+ result = QuorumClient("https://api.example", transport=transport).healthz()
83
+ self.assertEqual(result.error.retry_after_seconds, 7,
84
+ "a proxy may add a header the app did not")
85
+
86
+ def test_html_from_a_proxy_is_named_not_raised(self):
87
+ transport = transport_returning(502, b"<html>bad gateway</html>")
88
+ result = QuorumClient("https://api.example", transport=transport).healthz()
89
+ self.assertFalse(result.ok)
90
+ self.assertEqual(result.error.type, "bad_response")
91
+ self.assertEqual(result.error.status, 502)
92
+
93
+ def test_no_answer_at_all_is_status_zero(self):
94
+ def transport(method, url, headers, body, timeout):
95
+ raise OSError("connection refused")
96
+
97
+ result = QuorumClient("https://api.example", transport=transport).healthz()
98
+ self.assertFalse(result.ok)
99
+ self.assertEqual(result.error.status, 0,
100
+ "no server is a different problem from any status code")
101
+ self.assertEqual(result.error.type, "network")
102
+
103
+
104
+ class WaitForReport(unittest.TestCase):
105
+ def test_polls_until_terminal_and_honours_retry_after(self):
106
+ answers = [
107
+ (503, {"error": {"type": "overloaded", "message": "shed"}}, {"retry-after": "5"}),
108
+ (200, {"id": "rep_1", "status": "running"}, {}),
109
+ (200, {"id": "rep_1", "status": "complete"}, {}),
110
+ ]
111
+ slept = []
112
+
113
+ def transport(method, url, headers, body, timeout):
114
+ status, payload, response_headers = answers.pop(0)
115
+ return status, response_headers, json.dumps(payload).encode("utf-8")
116
+
117
+ client = QuorumClient("https://api.example", transport=transport)
118
+ result = client.wait_for_report("rep_1", poll_seconds=1.0,
119
+ sleep=slept.append,
120
+ clock=lambda: 0.0)
121
+ self.assertTrue(result.ok)
122
+ self.assertEqual(result.data["status"], "complete")
123
+ self.assertEqual(slept[0], 5.0, "the 503's Retry-After set the first wait")
124
+ self.assertEqual(slept[1], 1.0, "then the floor took over")
125
+
126
+ def test_gives_up_honestly_with_the_last_status_seen(self):
127
+ transport = transport_returning(200, {"id": "rep_1", "status": "running"})
128
+ ticks = iter([0.0, 0.0, 1000.0])
129
+
130
+ client = QuorumClient("https://api.example", transport=transport)
131
+ result = client.wait_for_report("rep_1", timeout_seconds=10.0,
132
+ sleep=lambda seconds: None,
133
+ clock=lambda: next(ticks))
134
+ self.assertFalse(result.ok)
135
+ self.assertEqual(result.error.type, "timeout")
136
+ self.assertIn("still running", result.error.message)
137
+
138
+ def test_a_real_error_returns_immediately(self):
139
+ transport = transport_returning(404, {"error": {
140
+ "type": "not_found", "message": "no report carries this id"}})
141
+ result = QuorumClient("https://api.example", transport=transport) \
142
+ .wait_for_report("rep_x", sleep=lambda seconds: None)
143
+ self.assertFalse(result.ok)
144
+ self.assertEqual(result.error.type, "not_found")
145
+
146
+
147
+ class StreamReport(unittest.TestCase):
148
+ def test_frames_survive_chunk_boundaries(self):
149
+ raw = (b"id: 1\nevent: phase\ndata: {\"name\": \"retrieve\"}\n\n"
150
+ b"id: 2\ndata: plain text\n\n")
151
+
152
+ class Response(io.BytesIO):
153
+ pass
154
+
155
+ client = QuorumClient("https://api.example", api_key="k")
156
+ events = list(client.stream_report(
157
+ "rep_1", opener=lambda request, timeout: Response(raw)))
158
+ self.assertEqual(events[0], {"id": 1, "type": "phase",
159
+ "data": {"name": "retrieve"}})
160
+ self.assertEqual(events[1], {"id": 2, "type": "message",
161
+ "data": "plain text"})
162
+
163
+ def test_a_stream_that_never_opens_is_empty_not_an_exception(self):
164
+ def opener(request, timeout):
165
+ raise OSError("refused")
166
+
167
+ client = QuorumClient("https://api.example")
168
+ self.assertEqual(list(client.stream_report("rep_1", opener=opener)), [])
169
+
170
+
171
+ class Shapes(unittest.TestCase):
172
+ def test_result_and_error_are_plain_data(self):
173
+ error = ApiError(type="x", message="y")
174
+ self.assertEqual(error.status, 0)
175
+ self.assertIsNone(Result(ok=True, data=1).error)
176
+
177
+
178
+ if __name__ == "__main__":
179
+ unittest.main()