ccflow-http 0.1.1__tar.gz → 0.2.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.
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/.gitignore +5 -0
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/PKG-INFO +5 -5
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/README.md +2 -2
- ccflow_http-0.2.0/ccflow_http/__init__.py +3 -0
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/ccflow_http/base.py +55 -28
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/ccflow_http/tests/test_model.py +135 -63
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/pyproject.toml +3 -3
- ccflow_http-0.1.1/ccflow_http/__init__.py +0 -3
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/LICENSE +0 -0
- {ccflow_http-0.1.1 → ccflow_http-0.2.0}/ccflow_http/tests/test_all.py +0 -0
|
@@ -13,6 +13,10 @@ __pycache__/
|
|
|
13
13
|
*.exp
|
|
14
14
|
*.lib
|
|
15
15
|
|
|
16
|
+
# vcpkg
|
|
17
|
+
vcpkg/
|
|
18
|
+
vcpkg_installed/
|
|
19
|
+
|
|
16
20
|
# Rust
|
|
17
21
|
target
|
|
18
22
|
target-capi
|
|
@@ -142,6 +146,7 @@ ccflow_http/labextension
|
|
|
142
146
|
|
|
143
147
|
# Emscripten SDK (locally installed)
|
|
144
148
|
emsdk
|
|
149
|
+
.pyodide_build/
|
|
145
150
|
|
|
146
151
|
# Mac
|
|
147
152
|
.DS_Store
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: ccflow-http
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: ccflow models for HTTP
|
|
5
5
|
Project-URL: Repository, https://github.com/1kbgz/ccflow-http
|
|
6
6
|
Project-URL: Homepage, https://github.com/1kbgz/ccflow-http
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
|
19
19
|
Requires-Python: >=3.11
|
|
20
20
|
Requires-Dist: ccflow-etl>=0.1.0
|
|
21
21
|
Requires-Dist: ccflow>=0.8.5
|
|
22
|
-
Requires-Dist:
|
|
22
|
+
Requires-Dist: httpx2
|
|
23
23
|
Requires-Dist: jinja2
|
|
24
24
|
Provides-Extra: develop
|
|
25
25
|
Requires-Dist: build; extra == 'develop'
|
|
@@ -55,7 +55,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
55
55
|
|
|
56
56
|
## Current Status
|
|
57
57
|
|
|
58
|
-
- Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, and mocked `
|
|
58
|
+
- Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, accepted error statuses returned as results instead of exceptions, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, redaction of secret query values from `httpx2` request logs, result URLs, and status errors, and mocked `httpx2` transport tests.
|
|
59
59
|
- Partial: the shared execution policy is currently consumed inside `HTTPModel` for sequential request spacing; broader evaluator-level concurrency coordination still belongs in evaluator integrations.
|
|
60
60
|
- Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
|
|
61
61
|
|
|
@@ -67,7 +67,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
67
67
|
|
|
68
68
|
## Test Convention
|
|
69
69
|
|
|
70
|
-
Default tests should use mocked `
|
|
70
|
+
Default tests should use mocked `httpx2` transports or local fixtures. They should not require live network calls or provider credentials.
|
|
71
71
|
|
|
72
72
|
> [!NOTE]
|
|
73
73
|
> This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).
|
|
@@ -15,7 +15,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
15
15
|
|
|
16
16
|
## Current Status
|
|
17
17
|
|
|
18
|
-
- Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, and mocked `
|
|
18
|
+
- Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, accepted error statuses returned as results instead of exceptions, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, redaction of secret query values from `httpx2` request logs, result URLs, and status errors, and mocked `httpx2` transport tests.
|
|
19
19
|
- Partial: the shared execution policy is currently consumed inside `HTTPModel` for sequential request spacing; broader evaluator-level concurrency coordination still belongs in evaluator integrations.
|
|
20
20
|
- Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
|
|
21
21
|
|
|
@@ -27,7 +27,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
27
27
|
|
|
28
28
|
## Test Convention
|
|
29
29
|
|
|
30
|
-
Default tests should use mocked `
|
|
30
|
+
Default tests should use mocked `httpx2` transports or local fixtures. They should not require live network calls or provider credentials.
|
|
31
31
|
|
|
32
32
|
> [!NOTE]
|
|
33
33
|
> This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).
|
|
@@ -4,11 +4,12 @@ from datetime import UTC, datetime
|
|
|
4
4
|
from email.utils import parsedate_to_datetime
|
|
5
5
|
from gzip import decompress
|
|
6
6
|
from io import StringIO
|
|
7
|
+
from logging import Filter, LogRecord, getLogger
|
|
7
8
|
from time import monotonic, sleep
|
|
8
9
|
from typing import Any, Literal
|
|
9
|
-
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
|
10
|
+
from urllib.parse import parse_qsl, unquote_plus, urlsplit, urlunsplit
|
|
10
11
|
|
|
11
|
-
import
|
|
12
|
+
import httpx2
|
|
12
13
|
from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, PyObjectPath
|
|
13
14
|
from ccflow.utils.retry import RetryPolicy
|
|
14
15
|
from ccflow_etl import ExecutionPolicy
|
|
@@ -26,6 +27,7 @@ __all__ = (
|
|
|
26
27
|
"HTTPResult",
|
|
27
28
|
"HTTPRetryPolicy",
|
|
28
29
|
"redact_mapping",
|
|
30
|
+
"redact_url",
|
|
29
31
|
"safe_request_dump",
|
|
30
32
|
)
|
|
31
33
|
|
|
@@ -75,15 +77,36 @@ class HTTPRequest(BaseModel):
|
|
|
75
77
|
content: bytes | str | None = None
|
|
76
78
|
|
|
77
79
|
|
|
80
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
81
|
+
normalized_key = key.lower().replace("_", "").replace("-", "")
|
|
82
|
+
return normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
|
|
83
|
+
|
|
84
|
+
|
|
78
85
|
def redact_mapping(values: dict[str, Any]) -> dict[str, Any]:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
return {key: "***" if _is_sensitive_key(key) else value for key, value in values.items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def redact_url(url: str) -> str:
|
|
90
|
+
parts = urlsplit(url)
|
|
91
|
+
if not parts.query:
|
|
92
|
+
return url
|
|
93
|
+
pairs = []
|
|
94
|
+
for pair in parts.query.split("&"):
|
|
95
|
+
key = pair.split("=", 1)[0]
|
|
96
|
+
pairs.append(f"{key}=***" if _is_sensitive_key(unquote_plus(key)) else pair)
|
|
97
|
+
return urlunsplit(parts._replace(query="&".join(pairs)))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _RedactURLFilter(Filter):
|
|
101
|
+
"""Redact secret query values from URLs that httpx2 logs for each request."""
|
|
102
|
+
|
|
103
|
+
def filter(self, record: LogRecord) -> bool:
|
|
104
|
+
if isinstance(record.args, tuple):
|
|
105
|
+
record.args = tuple(redact_url(str(arg)) if isinstance(arg, httpx2.URL) else arg for arg in record.args)
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
getLogger("httpx2").addFilter(_RedactURLFilter())
|
|
87
110
|
|
|
88
111
|
|
|
89
112
|
def safe_request_dump(request: HTTPRequest) -> dict[str, Any]:
|
|
@@ -105,9 +128,7 @@ class HTTPRetryEvent(BaseModel):
|
|
|
105
128
|
|
|
106
129
|
class HTTPRetryPolicy(RetryPolicy):
|
|
107
130
|
retry_status_codes: list[int] = Field(default_factory=lambda: [429, 500, 502, 503, 504])
|
|
108
|
-
retry_exceptions: list[PyObjectPath] = Field(
|
|
109
|
-
default_factory=lambda: [PyObjectPath.validate(httpx.TimeoutException), PyObjectPath.validate(httpx.ConnectError)]
|
|
110
|
-
)
|
|
131
|
+
retry_exceptions: list[PyObjectPath] = Field(default_factory=lambda: [PyObjectPath.validate(httpx2.TransportError)])
|
|
111
132
|
timeout_exception_types: list[str] = Field(
|
|
112
133
|
default_factory=lambda: ["TimeoutError", "TimeoutException", "ConnectTimeout", "ReadTimeout", "WriteTimeout", "PoolTimeout"]
|
|
113
134
|
)
|
|
@@ -199,6 +220,10 @@ class HTTPModel(CallableModel):
|
|
|
199
220
|
max_attempts: int = 1
|
|
200
221
|
retry_status_codes: list[int] = Field(default_factory=lambda: [429, 500, 502, 503, 504])
|
|
201
222
|
retry_policy: HTTPRetryPolicy | None = None
|
|
223
|
+
accepted_status_codes: list[int] = Field(
|
|
224
|
+
default_factory=list,
|
|
225
|
+
description="Error status codes returned as results with value None instead of raising, such as 404 for lookups that may miss.",
|
|
226
|
+
)
|
|
202
227
|
execution_policy: ExecutionPolicy | None = None
|
|
203
228
|
paginate: bool = False
|
|
204
229
|
max_pages: int = 100
|
|
@@ -309,7 +334,7 @@ class HTTPModel(CallableModel):
|
|
|
309
334
|
content=context.content if context.content is not None else self.content,
|
|
310
335
|
)
|
|
311
336
|
|
|
312
|
-
def _response_value(self, response:
|
|
337
|
+
def _response_value(self, response: httpx2.Response) -> Any:
|
|
313
338
|
match self.response_format:
|
|
314
339
|
case "json":
|
|
315
340
|
return response.json()
|
|
@@ -374,8 +399,8 @@ class HTTPModel(CallableModel):
|
|
|
374
399
|
return current
|
|
375
400
|
|
|
376
401
|
def _request_once(
|
|
377
|
-
self, client:
|
|
378
|
-
) -> tuple[
|
|
402
|
+
self, client: httpx2.Client, request: HTTPRequest, previous_started_at: float | None
|
|
403
|
+
) -> tuple[httpx2.Response, int, list[dict[str, Any]], float]:
|
|
379
404
|
attempts = 0
|
|
380
405
|
events: list[dict[str, Any]] = []
|
|
381
406
|
retry_policy = self._retry_policy()
|
|
@@ -392,9 +417,11 @@ class HTTPModel(CallableModel):
|
|
|
392
417
|
json=request.json_data,
|
|
393
418
|
content=request.content,
|
|
394
419
|
)
|
|
420
|
+
if response.status_code in self.accepted_status_codes:
|
|
421
|
+
return response, attempts, events, previous_started_at
|
|
395
422
|
response.raise_for_status()
|
|
396
423
|
return response, attempts, events, previous_started_at
|
|
397
|
-
except
|
|
424
|
+
except httpx2.HTTPStatusError as exc:
|
|
398
425
|
status_code = exc.response.status_code if exc.response is not None else None
|
|
399
426
|
if retry_policy.should_retry_status(status_code, attempts):
|
|
400
427
|
delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
|
|
@@ -425,8 +452,9 @@ class HTTPModel(CallableModel):
|
|
|
425
452
|
)
|
|
426
453
|
)
|
|
427
454
|
status_label = status_code if status_code is not None else "unknown"
|
|
428
|
-
|
|
429
|
-
|
|
455
|
+
# httpx2 status errors embed the full request URL, including secret query values.
|
|
456
|
+
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with status {status_label}") from None
|
|
457
|
+
except httpx2.TransportError as exc:
|
|
430
458
|
if retry_policy.should_retry_exception(exc, attempts):
|
|
431
459
|
delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
|
|
432
460
|
if delay_seconds is not None:
|
|
@@ -453,7 +481,7 @@ class HTTPModel(CallableModel):
|
|
|
453
481
|
)
|
|
454
482
|
)
|
|
455
483
|
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
|
|
456
|
-
except
|
|
484
|
+
except httpx2.HTTPError as exc:
|
|
457
485
|
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
|
|
458
486
|
|
|
459
487
|
def _merge_page_values(self, values: list[Any]) -> Any:
|
|
@@ -474,15 +502,11 @@ class HTTPModel(CallableModel):
|
|
|
474
502
|
def _request_with_params(self, request: HTTPRequest, params: dict[str, Any]) -> HTTPRequest:
|
|
475
503
|
return request.model_copy(update={"params": {**request.params, **params}})
|
|
476
504
|
|
|
477
|
-
def _is_sensitive_query_param(self, key: str) -> bool:
|
|
478
|
-
normalized_key = key.lower().replace("_", "").replace("-", "")
|
|
479
|
-
return normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
|
|
480
|
-
|
|
481
505
|
def _next_url_request(self, request: HTTPRequest, next_url: str) -> HTTPRequest:
|
|
482
506
|
next_url_parts = urlsplit(next_url)
|
|
483
507
|
next_url_params = dict(parse_qsl(next_url_parts.query, keep_blank_values=True))
|
|
484
508
|
for key, value in request.params.items():
|
|
485
|
-
if key not in next_url_params and
|
|
509
|
+
if key not in next_url_params and _is_sensitive_key(key):
|
|
486
510
|
next_url_params[key] = value
|
|
487
511
|
next_url_without_query = urlunsplit((next_url_parts.scheme, next_url_parts.netloc, next_url_parts.path, "", next_url_parts.fragment))
|
|
488
512
|
return request.model_copy(update={"url": next_url_without_query, "params": next_url_params})
|
|
@@ -534,7 +558,7 @@ class HTTPModel(CallableModel):
|
|
|
534
558
|
def __call__(self, context: HTTPRequestContext) -> HTTPResult:
|
|
535
559
|
request = self._initial_paginated_request(self.build_request(context))
|
|
536
560
|
|
|
537
|
-
with
|
|
561
|
+
with httpx2.Client(**self._client_kwargs()) as client:
|
|
538
562
|
values = []
|
|
539
563
|
retry_events = []
|
|
540
564
|
total_attempts = 0
|
|
@@ -545,6 +569,9 @@ class HTTPModel(CallableModel):
|
|
|
545
569
|
total_attempts += attempts
|
|
546
570
|
retry_events.extend(events)
|
|
547
571
|
pages += 1
|
|
572
|
+
accepted_error = response.status_code in self.accepted_status_codes
|
|
573
|
+
if accepted_error:
|
|
574
|
+
break
|
|
548
575
|
value = self._response_value(response)
|
|
549
576
|
values.append(value)
|
|
550
577
|
|
|
@@ -556,10 +583,10 @@ class HTTPModel(CallableModel):
|
|
|
556
583
|
request = next_request
|
|
557
584
|
|
|
558
585
|
return HTTPResult(
|
|
559
|
-
value=self._merge_page_values(values) if self.paginate else values[-1],
|
|
586
|
+
value=None if accepted_error else (self._merge_page_values(values) if self.paginate else values[-1]),
|
|
560
587
|
status_code=response.status_code,
|
|
561
588
|
headers=dict(response.headers or {}),
|
|
562
|
-
url=str(response.url),
|
|
589
|
+
url=redact_url(str(response.url)),
|
|
563
590
|
attempts=total_attempts,
|
|
564
591
|
pages=pages,
|
|
565
592
|
rate_limit=self._rate_limit_headers(dict(response.headers or {})),
|
|
@@ -6,11 +6,21 @@ from datetime import UTC
|
|
|
6
6
|
from gzip import compress
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import httpx2
|
|
10
10
|
import pytest
|
|
11
11
|
from ccflow_etl import ExecutionPolicy
|
|
12
12
|
|
|
13
|
-
from ccflow_http import
|
|
13
|
+
from ccflow_http import (
|
|
14
|
+
HTTPAuth,
|
|
15
|
+
HTTPConfig,
|
|
16
|
+
HTTPModel,
|
|
17
|
+
HTTPRequest,
|
|
18
|
+
HTTPRequestContext,
|
|
19
|
+
HTTPResponseResult,
|
|
20
|
+
HTTPRetryPolicy,
|
|
21
|
+
redact_url,
|
|
22
|
+
safe_request_dump,
|
|
23
|
+
)
|
|
14
24
|
|
|
15
25
|
|
|
16
26
|
@dataclass
|
|
@@ -52,7 +62,7 @@ def test_http_model_renders_request_and_returns_json(monkeypatch):
|
|
|
52
62
|
calls.append({"client": self.kwargs, "request": kwargs})
|
|
53
63
|
return FakeResponse(value={"status": "OK", "results": [{"ticker": "AAA"}]}, headers={"x-limit-remaining": "9"})
|
|
54
64
|
|
|
55
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
65
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
56
66
|
|
|
57
67
|
model = HTTPModel(
|
|
58
68
|
base_url="https://api.example.test",
|
|
@@ -133,11 +143,11 @@ def test_http_model_error_message_omits_secret_query_values(monkeypatch):
|
|
|
133
143
|
return False
|
|
134
144
|
|
|
135
145
|
def request(self, **kwargs):
|
|
136
|
-
request =
|
|
137
|
-
response =
|
|
138
|
-
raise
|
|
146
|
+
request = httpx2.Request("GET", "https://api.example.test/v1/tickers/AAA?apiKey=secret")
|
|
147
|
+
response = httpx2.Response(429, request=request)
|
|
148
|
+
raise httpx2.HTTPStatusError("rate limited with apiKey=secret", request=request, response=response)
|
|
139
149
|
|
|
140
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
150
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
141
151
|
|
|
142
152
|
model = HTTPModel(base_url="https://api.example.test", path="/v1/tickers/{{ ticker }}", query={"apiKey": "{{ api_key }}"})
|
|
143
153
|
|
|
@@ -146,6 +156,34 @@ def test_http_model_error_message_omits_secret_query_values(monkeypatch):
|
|
|
146
156
|
|
|
147
157
|
assert "secret" not in str(error.value)
|
|
148
158
|
assert "apiKey" not in str(error.value)
|
|
159
|
+
assert error.value.__cause__ is None
|
|
160
|
+
assert error.value.__suppress_context__
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_redact_url_masks_secret_query_values():
|
|
164
|
+
assert (
|
|
165
|
+
redact_url("https://api.example.test/v1/tickers?date=2024-01-03&apiKey=abc&page_token=def&limit=10")
|
|
166
|
+
== "https://api.example.test/v1/tickers?date=2024-01-03&apiKey=***&page_token=***&limit=10"
|
|
167
|
+
)
|
|
168
|
+
assert redact_url("https://api.example.test/v1/tickers") == "https://api.example.test/v1/tickers"
|
|
169
|
+
assert redact_url("/v1/tickers?cursor=2") == "/v1/tickers?cursor=2"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_http_model_redacts_secret_query_values_from_request_log_and_result(caplog):
|
|
173
|
+
transport = httpx2.MockTransport(lambda request: httpx2.Response(200, json={"status": "OK"}))
|
|
174
|
+
model = HTTPModel(
|
|
175
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=transport),
|
|
176
|
+
path="/v1/tickers",
|
|
177
|
+
query={"date": "2024-01-03"},
|
|
178
|
+
auth=HTTPAuth(strategy="api_key_query", name="apiKey", value="query-key"),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
with caplog.at_level("INFO", logger="httpx2"):
|
|
182
|
+
result = model(HTTPRequestContext())
|
|
183
|
+
|
|
184
|
+
assert "HTTP Request: GET https://api.example.test/v1/tickers?date=2024-01-03&apiKey=***" in caplog.text
|
|
185
|
+
assert "query-key" not in caplog.text
|
|
186
|
+
assert result.url == "https://api.example.test/v1/tickers?date=2024-01-03&apiKey=***"
|
|
149
187
|
|
|
150
188
|
|
|
151
189
|
def test_http_model_retries_retryable_status_and_captures_rate_limit(monkeypatch):
|
|
@@ -164,12 +202,12 @@ def test_http_model_retries_retryable_status_and_captures_rate_limit(monkeypatch
|
|
|
164
202
|
def request(self, **kwargs):
|
|
165
203
|
calls.append(kwargs)
|
|
166
204
|
if len(calls) == 1:
|
|
167
|
-
request =
|
|
168
|
-
response =
|
|
169
|
-
raise
|
|
205
|
+
request = httpx2.Request("GET", "https://api.example.test/v1/tickers")
|
|
206
|
+
response = httpx2.Response(429, request=request, headers={"retry-after": "1"})
|
|
207
|
+
raise httpx2.HTTPStatusError("rate limited", request=request, response=response)
|
|
170
208
|
return FakeResponse(value={"status": "OK"}, headers={"x-ratelimit-remaining": "8"})
|
|
171
209
|
|
|
172
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
210
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
173
211
|
|
|
174
212
|
result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
|
|
175
213
|
|
|
@@ -195,12 +233,12 @@ def test_http_model_consumes_shared_retry_policy(monkeypatch):
|
|
|
195
233
|
def request(self, **kwargs):
|
|
196
234
|
calls.append(kwargs)
|
|
197
235
|
if len(calls) == 1:
|
|
198
|
-
request =
|
|
199
|
-
response =
|
|
200
|
-
raise
|
|
236
|
+
request = httpx2.Request("GET", "https://api.example.test/v1/tickers")
|
|
237
|
+
response = httpx2.Response(503, request=request)
|
|
238
|
+
raise httpx2.HTTPStatusError("unavailable", request=request, response=response)
|
|
201
239
|
return FakeResponse(value={"status": "OK"})
|
|
202
240
|
|
|
203
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
241
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
204
242
|
|
|
205
243
|
model = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", retry_policy=HTTPRetryPolicy(max_attempts=2, retry_status_codes=[503]))
|
|
206
244
|
|
|
@@ -223,12 +261,12 @@ def test_http_model_consumes_shared_retry_delay_and_execution_policy(monkeypatch
|
|
|
223
261
|
def request(self, **kwargs):
|
|
224
262
|
calls.append(kwargs)
|
|
225
263
|
if len(calls) == 1:
|
|
226
|
-
request =
|
|
227
|
-
response =
|
|
228
|
-
raise
|
|
264
|
+
request = httpx2.Request("GET", "https://api.example.test/v1/tickers")
|
|
265
|
+
response = httpx2.Response(429, request=request)
|
|
266
|
+
raise httpx2.HTTPStatusError("rate limited", request=request, response=response)
|
|
229
267
|
return FakeResponse(value={"status": "OK"})
|
|
230
268
|
|
|
231
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
269
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
232
270
|
|
|
233
271
|
model = HTTPModel(
|
|
234
272
|
base_url="https://api.example.test",
|
|
@@ -274,10 +312,10 @@ def test_http_model_retries_timeout_exception(monkeypatch):
|
|
|
274
312
|
def request(self, **kwargs):
|
|
275
313
|
calls.append(kwargs)
|
|
276
314
|
if len(calls) == 1:
|
|
277
|
-
raise
|
|
315
|
+
raise httpx2.TimeoutException("timed out")
|
|
278
316
|
return FakeResponse(value={"status": "OK"})
|
|
279
317
|
|
|
280
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
318
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
281
319
|
|
|
282
320
|
result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
|
|
283
321
|
|
|
@@ -301,11 +339,11 @@ def test_http_model_retries_5xx_until_attempts_are_exhausted(monkeypatch):
|
|
|
301
339
|
|
|
302
340
|
def request(self, **kwargs):
|
|
303
341
|
calls.append(kwargs)
|
|
304
|
-
request =
|
|
305
|
-
response =
|
|
306
|
-
raise
|
|
342
|
+
request = httpx2.Request("GET", "https://api.example.test/v1/tickers?apiKey=secret")
|
|
343
|
+
response = httpx2.Response(500, request=request)
|
|
344
|
+
raise httpx2.HTTPStatusError("server error with apiKey=secret", request=request, response=response)
|
|
307
345
|
|
|
308
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
346
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
309
347
|
|
|
310
348
|
model = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", query={"apiKey": "secret"}, max_attempts=2)
|
|
311
349
|
|
|
@@ -336,7 +374,7 @@ def test_http_model_paginates_massive_style_next_url(monkeypatch):
|
|
|
336
374
|
return FakeResponse(value={"results": [{"ticker": "AAA"}], "next_url": "/v3/reference/tickers?cursor=2"})
|
|
337
375
|
return FakeResponse(value={"results": [{"ticker": "BBB"}]})
|
|
338
376
|
|
|
339
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
377
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
340
378
|
|
|
341
379
|
result = HTTPModel(base_url="https://api.example.test", path="/v3/reference/tickers", paginate=True)(HTTPRequestContext())
|
|
342
380
|
|
|
@@ -365,7 +403,7 @@ def test_http_model_preserves_query_auth_for_next_url_pagination(monkeypatch):
|
|
|
365
403
|
return FakeResponse(value={"results": [{"ticker": "AAA"}], "next_url": "/v3/reference/tickers?cursor=2"})
|
|
366
404
|
return FakeResponse(value={"results": [{"ticker": "BBB"}]})
|
|
367
405
|
|
|
368
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
406
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
369
407
|
|
|
370
408
|
HTTPModel(
|
|
371
409
|
base_url="https://api.example.test",
|
|
@@ -378,17 +416,17 @@ def test_http_model_preserves_query_auth_for_next_url_pagination(monkeypatch):
|
|
|
378
416
|
assert calls[1]["params"] == {"cursor": "2", "apiKey": "secret"}
|
|
379
417
|
|
|
380
418
|
|
|
381
|
-
def
|
|
419
|
+
def test_http_model_sends_next_url_query_and_preserved_auth_with_httpx2_transport():
|
|
382
420
|
seen_urls = []
|
|
383
421
|
|
|
384
|
-
def handler(request:
|
|
422
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
385
423
|
seen_urls.append(str(request.url))
|
|
386
424
|
if len(seen_urls) == 1:
|
|
387
|
-
return
|
|
388
|
-
return
|
|
425
|
+
return httpx2.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v3/reference/tickers?cursor=2"})
|
|
426
|
+
return httpx2.Response(200, json={"results": [{"ticker": "BBB"}]})
|
|
389
427
|
|
|
390
428
|
model = HTTPModel(
|
|
391
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
429
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
392
430
|
path="/v3/reference/tickers",
|
|
393
431
|
query={"apiKey": "secret"},
|
|
394
432
|
paginate=True,
|
|
@@ -406,11 +444,11 @@ def test_http_model_sends_next_url_query_and_preserved_auth_with_httpx_transport
|
|
|
406
444
|
def test_http_model_applies_config_and_all_auth_strategies_with_mock_transport():
|
|
407
445
|
seen_requests = []
|
|
408
446
|
|
|
409
|
-
def handler(request:
|
|
447
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
410
448
|
seen_requests.append(request)
|
|
411
|
-
return
|
|
449
|
+
return httpx2.Response(200, json={"status": "OK"}, headers={"x-ratelimit-remaining": "7"})
|
|
412
450
|
|
|
413
|
-
transport =
|
|
451
|
+
transport = httpx2.MockTransport(handler)
|
|
414
452
|
config = HTTPConfig(base_url="https://api.example.test", timeout=4.0, headers={"Accept": "application/json"}, transport=transport)
|
|
415
453
|
|
|
416
454
|
bearer = HTTPModel(config=config, path="/v1/{{ resource }}", auth=HTTPAuth(strategy="bearer", token="{{ token }}"))
|
|
@@ -439,21 +477,54 @@ def test_http_model_applies_config_and_all_auth_strategies_with_mock_transport()
|
|
|
439
477
|
assert "authorization" not in seen_requests[4].headers
|
|
440
478
|
|
|
441
479
|
|
|
480
|
+
def test_http_model_returns_accepted_error_status_without_raising():
|
|
481
|
+
transport = httpx2.MockTransport(lambda request: httpx2.Response(404, json={"status": "NOT_FOUND"}))
|
|
482
|
+
config = HTTPConfig(base_url="https://api.example.test", transport=transport)
|
|
483
|
+
|
|
484
|
+
result = HTTPModel(config=config, path="/v1/tickers/ZZZZ", accepted_status_codes=[404])(HTTPRequestContext())
|
|
485
|
+
|
|
486
|
+
assert result.status_code == 404
|
|
487
|
+
assert result.value is None
|
|
488
|
+
assert result.retry_summary == {"attempts": 1, "retried": 0, "failed": 0, "succeeded": 1}
|
|
489
|
+
with pytest.raises(RuntimeError, match="failed with status 404"):
|
|
490
|
+
HTTPModel(config=config, path="/v1/tickers/ZZZZ")(HTTPRequestContext())
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_http_model_stops_pagination_on_accepted_error_status():
|
|
494
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
495
|
+
if "cursor" in request.url.params:
|
|
496
|
+
return httpx2.Response(404)
|
|
497
|
+
return httpx2.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v1/tickers?cursor=2"})
|
|
498
|
+
|
|
499
|
+
model = HTTPModel(
|
|
500
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
501
|
+
path="/v1/tickers",
|
|
502
|
+
paginate=True,
|
|
503
|
+
accepted_status_codes=[404],
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
result = model(HTTPRequestContext())
|
|
507
|
+
|
|
508
|
+
assert result.status_code == 404
|
|
509
|
+
assert result.value is None
|
|
510
|
+
assert result.pages == 2
|
|
511
|
+
|
|
512
|
+
|
|
442
513
|
def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
|
|
443
|
-
def csv_handler(request:
|
|
444
|
-
return
|
|
514
|
+
def csv_handler(request: httpx2.Request) -> httpx2.Response:
|
|
515
|
+
return httpx2.Response(200, text="ticker,volume\nAAA,10\nBBB,20\n")
|
|
445
516
|
|
|
446
517
|
csv_result = HTTPModel(
|
|
447
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
518
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(csv_handler)),
|
|
448
519
|
path="/csv",
|
|
449
520
|
response_format="csv",
|
|
450
521
|
)(HTTPRequestContext())
|
|
451
522
|
|
|
452
|
-
def gzip_handler(request:
|
|
453
|
-
return
|
|
523
|
+
def gzip_handler(request: httpx2.Request) -> httpx2.Response:
|
|
524
|
+
return httpx2.Response(200, content=compress(b'{"status":"OK"}'))
|
|
454
525
|
|
|
455
526
|
gzip_result = HTTPModel(
|
|
456
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
527
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(gzip_handler)),
|
|
457
528
|
path="/gzip",
|
|
458
529
|
response_format="gzip",
|
|
459
530
|
)(HTTPRequestContext())
|
|
@@ -465,14 +536,14 @@ def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
|
|
|
465
536
|
def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transport():
|
|
466
537
|
cursor_requests = []
|
|
467
538
|
|
|
468
|
-
def cursor_handler(request:
|
|
539
|
+
def cursor_handler(request: httpx2.Request) -> httpx2.Response:
|
|
469
540
|
cursor_requests.append(dict(request.url.params))
|
|
470
541
|
if "cursor" not in request.url.params:
|
|
471
|
-
return
|
|
472
|
-
return
|
|
542
|
+
return httpx2.Response(200, json={"results": [{"id": 1}], "next_cursor": "abc"})
|
|
543
|
+
return httpx2.Response(200, json={"results": [{"id": 2}]})
|
|
473
544
|
|
|
474
545
|
cursor_result = HTTPModel(
|
|
475
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
546
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(cursor_handler)),
|
|
476
547
|
path="/items",
|
|
477
548
|
paginate=True,
|
|
478
549
|
pagination_mode="cursor",
|
|
@@ -482,14 +553,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
|
|
|
482
553
|
|
|
483
554
|
page_requests = []
|
|
484
555
|
|
|
485
|
-
def page_handler(request:
|
|
556
|
+
def page_handler(request: httpx2.Request) -> httpx2.Response:
|
|
486
557
|
page_requests.append(dict(request.url.params))
|
|
487
558
|
page = int(request.url.params["page"])
|
|
488
559
|
payload = {1: [{"id": 1}], 2: [{"id": 2}]}.get(page, [])
|
|
489
|
-
return
|
|
560
|
+
return httpx2.Response(200, json={"results": payload})
|
|
490
561
|
|
|
491
562
|
page_result = HTTPModel(
|
|
492
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
563
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(page_handler)),
|
|
493
564
|
path="/items",
|
|
494
565
|
paginate=True,
|
|
495
566
|
pagination_mode="page",
|
|
@@ -500,14 +571,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
|
|
|
500
571
|
|
|
501
572
|
offset_requests = []
|
|
502
573
|
|
|
503
|
-
def offset_handler(request:
|
|
574
|
+
def offset_handler(request: httpx2.Request) -> httpx2.Response:
|
|
504
575
|
offset_requests.append(dict(request.url.params))
|
|
505
576
|
offset = int(request.url.params["offset"])
|
|
506
577
|
payload = {0: [{"id": 1}], 2: [{"id": 2}]}.get(offset, [])
|
|
507
|
-
return
|
|
578
|
+
return httpx2.Response(200, json={"results": payload})
|
|
508
579
|
|
|
509
580
|
offset_result = HTTPModel(
|
|
510
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
581
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(offset_handler)),
|
|
511
582
|
path="/items",
|
|
512
583
|
paginate=True,
|
|
513
584
|
pagination_mode="offset",
|
|
@@ -546,29 +617,30 @@ def _retrying_client(calls, response_factory):
|
|
|
546
617
|
|
|
547
618
|
|
|
548
619
|
def _raise_status(status_code, headers=None):
|
|
549
|
-
request =
|
|
550
|
-
response =
|
|
551
|
-
raise
|
|
620
|
+
request = httpx2.Request("GET", "https://api.example.test/v1/tickers")
|
|
621
|
+
response = httpx2.Response(status_code, request=request, headers=headers or {})
|
|
622
|
+
raise httpx2.HTTPStatusError("error", request=request, response=response)
|
|
552
623
|
|
|
553
624
|
|
|
554
|
-
|
|
625
|
+
@pytest.mark.parametrize(("exception_type", "category"), [(httpx2.ConnectError, "connection"), (httpx2.RemoteProtocolError, "exception")])
|
|
626
|
+
def test_http_model_retries_transport_error(monkeypatch, exception_type, category):
|
|
555
627
|
calls = []
|
|
556
628
|
|
|
557
629
|
def fail_once():
|
|
558
|
-
raise
|
|
630
|
+
raise exception_type("transport failed")
|
|
559
631
|
|
|
560
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
632
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, fail_once))
|
|
561
633
|
|
|
562
634
|
result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
|
|
563
635
|
|
|
564
636
|
assert len(calls) == 2
|
|
565
637
|
assert result.attempts == 2
|
|
566
|
-
assert result.retry_events[0]["category"] ==
|
|
638
|
+
assert result.retry_events[0]["category"] == category
|
|
567
639
|
|
|
568
640
|
|
|
569
641
|
def test_http_model_honors_retry_after_seconds_over_backoff(monkeypatch):
|
|
570
642
|
calls = []
|
|
571
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
643
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "45"})))
|
|
572
644
|
|
|
573
645
|
model = HTTPModel(
|
|
574
646
|
base_url="https://api.example.test",
|
|
@@ -587,7 +659,7 @@ def test_http_model_honors_retry_after_seconds_over_backoff(monkeypatch):
|
|
|
587
659
|
|
|
588
660
|
def test_http_model_uses_backoff_when_it_exceeds_retry_after(monkeypatch):
|
|
589
661
|
calls = []
|
|
590
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
662
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "1"})))
|
|
591
663
|
|
|
592
664
|
model = HTTPModel(
|
|
593
665
|
base_url="https://api.example.test",
|
|
@@ -604,7 +676,7 @@ def test_http_model_uses_backoff_when_it_exceeds_retry_after(monkeypatch):
|
|
|
604
676
|
|
|
605
677
|
def test_http_model_caps_retry_after_at_retry_after_max(monkeypatch):
|
|
606
678
|
calls = []
|
|
607
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
679
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "9999"})))
|
|
608
680
|
|
|
609
681
|
model = HTTPModel(
|
|
610
682
|
base_url="https://api.example.test",
|
|
@@ -625,7 +697,7 @@ def test_http_model_honors_retry_after_http_date(monkeypatch):
|
|
|
625
697
|
|
|
626
698
|
calls = []
|
|
627
699
|
retry_at = format_datetime(datetime.now(UTC) + timedelta(seconds=60))
|
|
628
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
700
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": retry_at})))
|
|
629
701
|
|
|
630
702
|
model = HTTPModel(
|
|
631
703
|
base_url="https://api.example.test",
|
|
@@ -643,7 +715,7 @@ def test_http_model_honors_retry_after_http_date(monkeypatch):
|
|
|
643
715
|
|
|
644
716
|
def test_http_model_ignores_invalid_retry_after(monkeypatch):
|
|
645
717
|
calls = []
|
|
646
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
718
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "soon"})))
|
|
647
719
|
|
|
648
720
|
model = HTTPModel(
|
|
649
721
|
base_url="https://api.example.test",
|
|
@@ -12,7 +12,7 @@ authors = [
|
|
|
12
12
|
description = "ccflow models for HTTP"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
license = { text = "Apache-2.0" }
|
|
15
|
-
version = "0.
|
|
15
|
+
version = "0.2.0"
|
|
16
16
|
requires-python = ">=3.11"
|
|
17
17
|
keywords = []
|
|
18
18
|
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
dependencies = [
|
|
32
32
|
"ccflow>=0.8.5",
|
|
33
33
|
"ccflow-etl>=0.1.0",
|
|
34
|
-
"
|
|
34
|
+
"httpx2",
|
|
35
35
|
"jinja2",
|
|
36
36
|
]
|
|
37
37
|
|
|
@@ -60,7 +60,7 @@ Repository = "https://github.com/1kbgz/ccflow-http"
|
|
|
60
60
|
Homepage = "https://github.com/1kbgz/ccflow-http"
|
|
61
61
|
|
|
62
62
|
[tool.bumpversion]
|
|
63
|
-
current_version = "0.
|
|
63
|
+
current_version = "0.2.0"
|
|
64
64
|
commit = true
|
|
65
65
|
tag = true
|
|
66
66
|
commit_args = "-s"
|
|
File without changes
|
|
File without changes
|