ccflow-http 0.1.0__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.0 → ccflow_http-0.2.0}/.gitignore +7 -2
- {ccflow_http-0.1.0 → ccflow_http-0.2.0}/PKG-INFO +6 -7
- {ccflow_http-0.1.0 → ccflow_http-0.2.0}/README.md +2 -2
- ccflow_http-0.2.0/ccflow_http/__init__.py +3 -0
- {ccflow_http-0.1.0 → ccflow_http-0.2.0}/ccflow_http/base.py +146 -93
- {ccflow_http-0.1.0 → ccflow_http-0.2.0}/ccflow_http/tests/test_all.py +1 -1
- {ccflow_http-0.1.0 → ccflow_http-0.2.0}/ccflow_http/tests/test_model.py +257 -51
- {ccflow_http-0.1.0 → ccflow_http-0.2.0}/pyproject.toml +4 -5
- ccflow_http-0.1.0/ccflow_http/__init__.py +0 -3
- {ccflow_http-0.1.0 → ccflow_http-0.2.0}/LICENSE +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,13 +146,14 @@ 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
|
|
148
153
|
|
|
149
154
|
# Hydra
|
|
150
|
-
outputs/
|
|
151
|
-
multirun/
|
|
155
|
+
/outputs/
|
|
156
|
+
/multirun/
|
|
152
157
|
|
|
153
158
|
# AI
|
|
154
159
|
ROADMAP.md
|
|
@@ -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
|
|
@@ -10,17 +10,16 @@ License-File: LICENSE
|
|
|
10
10
|
Classifier: Development Status :: 3 - Alpha
|
|
11
11
|
Classifier: Programming Language :: Python
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
17
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
19
18
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
20
|
-
Requires-Python: >=3.
|
|
19
|
+
Requires-Python: >=3.11
|
|
21
20
|
Requires-Dist: ccflow-etl>=0.1.0
|
|
22
21
|
Requires-Dist: ccflow>=0.8.5
|
|
23
|
-
Requires-Dist:
|
|
22
|
+
Requires-Dist: httpx2
|
|
24
23
|
Requires-Dist: jinja2
|
|
25
24
|
Provides-Extra: develop
|
|
26
25
|
Requires-Dist: build; extra == 'develop'
|
|
@@ -56,7 +55,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
56
55
|
|
|
57
56
|
## Current Status
|
|
58
57
|
|
|
59
|
-
- 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.
|
|
60
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.
|
|
61
60
|
- Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
|
|
62
61
|
|
|
@@ -68,7 +67,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
68
67
|
|
|
69
68
|
## Test Convention
|
|
70
69
|
|
|
71
|
-
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.
|
|
72
71
|
|
|
73
72
|
> [!NOTE]
|
|
74
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).
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
from base64 import b64encode
|
|
2
2
|
from csv import DictReader
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from email.utils import parsedate_to_datetime
|
|
3
5
|
from gzip import decompress
|
|
4
6
|
from io import StringIO
|
|
7
|
+
from logging import Filter, LogRecord, getLogger
|
|
5
8
|
from time import monotonic, sleep
|
|
6
|
-
from typing import Any,
|
|
7
|
-
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
from urllib.parse import parse_qsl, unquote_plus, urlsplit, urlunsplit
|
|
8
11
|
|
|
9
|
-
import
|
|
12
|
+
import httpx2
|
|
10
13
|
from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, PyObjectPath
|
|
11
14
|
from ccflow.utils.retry import RetryPolicy
|
|
12
15
|
from ccflow_etl import ExecutionPolicy
|
|
@@ -14,16 +17,17 @@ from jinja2 import Environment
|
|
|
14
17
|
from pydantic import Field
|
|
15
18
|
|
|
16
19
|
__all__ = (
|
|
17
|
-
"HTTPConfig",
|
|
18
20
|
"HTTPAuth",
|
|
21
|
+
"HTTPConfig",
|
|
19
22
|
"HTTPContext",
|
|
20
|
-
"
|
|
23
|
+
"HTTPModel",
|
|
21
24
|
"HTTPRequest",
|
|
22
|
-
"
|
|
25
|
+
"HTTPRequestContext",
|
|
23
26
|
"HTTPResponseResult",
|
|
24
27
|
"HTTPResult",
|
|
25
|
-
"
|
|
28
|
+
"HTTPRetryPolicy",
|
|
26
29
|
"redact_mapping",
|
|
30
|
+
"redact_url",
|
|
27
31
|
"safe_request_dump",
|
|
28
32
|
)
|
|
29
33
|
|
|
@@ -38,27 +42,27 @@ class HTTPConfig(BaseModel):
|
|
|
38
42
|
base_url: str = ""
|
|
39
43
|
timeout: float = 30.0
|
|
40
44
|
follow_redirects: bool = True
|
|
41
|
-
headers:
|
|
42
|
-
transport:
|
|
45
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
46
|
+
transport: Any | None = None
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
class HTTPAuth(BaseModel):
|
|
46
50
|
strategy: HTTPAuthStrategy = "none"
|
|
47
|
-
token:
|
|
48
|
-
name:
|
|
49
|
-
value:
|
|
50
|
-
username:
|
|
51
|
-
password:
|
|
51
|
+
token: str | None = None
|
|
52
|
+
name: str | None = None
|
|
53
|
+
value: str | None = None
|
|
54
|
+
username: str | None = None
|
|
55
|
+
password: str | None = None
|
|
52
56
|
scheme: str = "Bearer"
|
|
53
57
|
|
|
54
58
|
|
|
55
59
|
class HTTPContext(ContextBase):
|
|
56
|
-
path:
|
|
57
|
-
query:
|
|
58
|
-
headers:
|
|
59
|
-
template_values:
|
|
60
|
-
json_body:
|
|
61
|
-
content:
|
|
60
|
+
path: str | None = None
|
|
61
|
+
query: dict[str, Any] = Field(default_factory=dict)
|
|
62
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
63
|
+
template_values: dict[str, Any] = Field(default_factory=dict)
|
|
64
|
+
json_body: Any | None = None
|
|
65
|
+
content: bytes | str | None = None
|
|
62
66
|
|
|
63
67
|
|
|
64
68
|
class HTTPRequestContext(HTTPContext): ...
|
|
@@ -67,24 +71,45 @@ class HTTPRequestContext(HTTPContext): ...
|
|
|
67
71
|
class HTTPRequest(BaseModel):
|
|
68
72
|
method: HTTPMethod
|
|
69
73
|
url: str
|
|
70
|
-
params:
|
|
71
|
-
headers:
|
|
72
|
-
json_data:
|
|
73
|
-
content:
|
|
74
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
75
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
76
|
+
json_data: Any | None = None
|
|
77
|
+
content: bytes | str | None = None
|
|
78
|
+
|
|
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
|
+
|
|
85
|
+
def redact_mapping(values: dict[str, Any]) -> dict[str, Any]:
|
|
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)))
|
|
74
98
|
|
|
75
99
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
for key, value in values.items():
|
|
79
|
-
normalized_key = key.lower().replace("_", "").replace("-", "")
|
|
80
|
-
if normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key:
|
|
81
|
-
redacted[key] = "***"
|
|
82
|
-
else:
|
|
83
|
-
redacted[key] = value
|
|
84
|
-
return redacted
|
|
100
|
+
class _RedactURLFilter(Filter):
|
|
101
|
+
"""Redact secret query values from URLs that httpx2 logs for each request."""
|
|
85
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
|
|
86
107
|
|
|
87
|
-
|
|
108
|
+
|
|
109
|
+
getLogger("httpx2").addFilter(_RedactURLFilter())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def safe_request_dump(request: HTTPRequest) -> dict[str, Any]:
|
|
88
113
|
request_data = request.model_dump(exclude={"type_"})
|
|
89
114
|
request_data["params"] = redact_mapping(request_data.get("params", {}))
|
|
90
115
|
request_data["headers"] = redact_mapping(request_data.get("headers", {}))
|
|
@@ -95,33 +120,52 @@ class HTTPRetryEvent(BaseModel):
|
|
|
95
120
|
attempt: int
|
|
96
121
|
outcome: HTTPRetryOutcome
|
|
97
122
|
delay_seconds: float = 0.0
|
|
98
|
-
status_code:
|
|
99
|
-
exception_type:
|
|
100
|
-
category:
|
|
101
|
-
message:
|
|
123
|
+
status_code: int | None = None
|
|
124
|
+
exception_type: str | None = None
|
|
125
|
+
category: str | None = None
|
|
126
|
+
message: str | None = None
|
|
102
127
|
|
|
103
128
|
|
|
104
129
|
class HTTPRetryPolicy(RetryPolicy):
|
|
105
|
-
retry_status_codes:
|
|
106
|
-
retry_exceptions:
|
|
107
|
-
|
|
108
|
-
)
|
|
109
|
-
timeout_exception_types: List[str] = Field(
|
|
130
|
+
retry_status_codes: list[int] = Field(default_factory=lambda: [429, 500, 502, 503, 504])
|
|
131
|
+
retry_exceptions: list[PyObjectPath] = Field(default_factory=lambda: [PyObjectPath.validate(httpx2.TransportError)])
|
|
132
|
+
timeout_exception_types: list[str] = Field(
|
|
110
133
|
default_factory=lambda: ["TimeoutError", "TimeoutException", "ConnectTimeout", "ReadTimeout", "WriteTimeout", "PoolTimeout"]
|
|
111
134
|
)
|
|
135
|
+
retry_after_max: float | None = Field(
|
|
136
|
+
default=300.0,
|
|
137
|
+
ge=0.0,
|
|
138
|
+
description="Cap (in seconds) on honoring a server-provided Retry-After header. None honors it unbounded.",
|
|
139
|
+
)
|
|
112
140
|
|
|
113
|
-
def should_retry_status(self, status_code:
|
|
141
|
+
def should_retry_status(self, status_code: int | None, attempt: int) -> bool:
|
|
114
142
|
return status_code in self.retry_status_codes and attempt < self.max_attempts
|
|
115
143
|
|
|
144
|
+
def retry_after_seconds(self, headers: dict[str, str]) -> float | None:
|
|
145
|
+
value = next((v for k, v in headers.items() if k.lower() == "retry-after"), None)
|
|
146
|
+
if value is None:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
delay_seconds = float(value)
|
|
150
|
+
except ValueError:
|
|
151
|
+
try:
|
|
152
|
+
delay_seconds = (parsedate_to_datetime(value) - datetime.now(UTC)).total_seconds()
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
return None
|
|
155
|
+
delay_seconds = max(delay_seconds, 0.0)
|
|
156
|
+
if self.retry_after_max is not None:
|
|
157
|
+
delay_seconds = min(delay_seconds, self.retry_after_max)
|
|
158
|
+
return delay_seconds
|
|
159
|
+
|
|
116
160
|
def should_retry_exception(self, exception: BaseException, attempt: int) -> bool:
|
|
117
161
|
return self._should_retry(exception) and attempt < self.max_attempts
|
|
118
162
|
|
|
119
|
-
def delay_seconds(self, attempt: int, jitter_value:
|
|
163
|
+
def delay_seconds(self, attempt: int, jitter_value: float | None = None) -> float:
|
|
120
164
|
if attempt < 1:
|
|
121
165
|
raise ValueError("attempt must be greater than or equal to 1")
|
|
122
166
|
return self._compute_delay(attempt, jitter_value=jitter_value)
|
|
123
167
|
|
|
124
|
-
def retry_delay_seconds(self, attempt: int, total_wait_seconds: float) ->
|
|
168
|
+
def retry_delay_seconds(self, attempt: int, total_wait_seconds: float) -> float | None:
|
|
125
169
|
delay_seconds = self.delay_seconds(attempt)
|
|
126
170
|
if self.max_delay is not None and total_wait_seconds + delay_seconds > self.max_delay:
|
|
127
171
|
return None
|
|
@@ -136,7 +180,7 @@ class HTTPRetryPolicy(RetryPolicy):
|
|
|
136
180
|
return "connection"
|
|
137
181
|
return "exception"
|
|
138
182
|
|
|
139
|
-
def status_category(self, status_code:
|
|
183
|
+
def status_category(self, status_code: int | None) -> str:
|
|
140
184
|
if status_code == 429:
|
|
141
185
|
return "rate_limit"
|
|
142
186
|
if status_code == 408:
|
|
@@ -148,48 +192,52 @@ class HTTPRetryPolicy(RetryPolicy):
|
|
|
148
192
|
|
|
149
193
|
class HTTPResponseResult(GenericResult[Any]):
|
|
150
194
|
status_code: int
|
|
151
|
-
headers:
|
|
195
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
152
196
|
url: str = ""
|
|
153
197
|
attempts: int = 1
|
|
154
198
|
pages: int = 1
|
|
155
|
-
rate_limit:
|
|
156
|
-
retry_events:
|
|
157
|
-
retry_summary:
|
|
199
|
+
rate_limit: dict[str, str] = Field(default_factory=dict)
|
|
200
|
+
retry_events: list[dict[str, Any]] = Field(default_factory=list)
|
|
201
|
+
retry_summary: dict[str, int] = Field(default_factory=dict)
|
|
158
202
|
|
|
159
203
|
|
|
160
204
|
class HTTPResult(HTTPResponseResult): ...
|
|
161
205
|
|
|
162
206
|
|
|
163
207
|
class HTTPModel(CallableModel):
|
|
164
|
-
config:
|
|
208
|
+
config: HTTPConfig | None = None
|
|
165
209
|
auth: HTTPAuth = Field(default_factory=HTTPAuth)
|
|
166
210
|
method: HTTPMethod = "GET"
|
|
167
211
|
base_url: str = ""
|
|
168
212
|
path: str = ""
|
|
169
|
-
query:
|
|
170
|
-
headers:
|
|
213
|
+
query: dict[str, Any] = Field(default_factory=dict)
|
|
214
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
171
215
|
timeout: float = 30.0
|
|
172
216
|
follow_redirects: bool = True
|
|
173
217
|
response_format: ResponseFormat = "json"
|
|
174
|
-
json_body:
|
|
175
|
-
content:
|
|
218
|
+
json_body: Any | None = None
|
|
219
|
+
content: bytes | str | None = None
|
|
176
220
|
max_attempts: int = 1
|
|
177
|
-
retry_status_codes:
|
|
178
|
-
retry_policy:
|
|
179
|
-
|
|
221
|
+
retry_status_codes: list[int] = Field(default_factory=lambda: [429, 500, 502, 503, 504])
|
|
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
|
+
)
|
|
227
|
+
execution_policy: ExecutionPolicy | None = None
|
|
180
228
|
paginate: bool = False
|
|
181
229
|
max_pages: int = 100
|
|
182
230
|
pagination_mode: HTTPPaginationMode = "next_url"
|
|
183
231
|
next_url_field: str = "next_url"
|
|
184
232
|
next_cursor_field: str = "next_cursor"
|
|
185
233
|
cursor_param: str = "cursor"
|
|
186
|
-
cursor_start:
|
|
234
|
+
cursor_start: str | None = None
|
|
187
235
|
page_param: str = "page"
|
|
188
236
|
page_start: int = 1
|
|
189
237
|
offset_param: str = "offset"
|
|
190
238
|
offset_start: int = 0
|
|
191
239
|
limit_param: str = "limit"
|
|
192
|
-
limit:
|
|
240
|
+
limit: int | None = None
|
|
193
241
|
results_field: str = "results"
|
|
194
242
|
|
|
195
243
|
@property
|
|
@@ -200,25 +248,25 @@ class HTTPModel(CallableModel):
|
|
|
200
248
|
def result_type(self):
|
|
201
249
|
return HTTPResult
|
|
202
250
|
|
|
203
|
-
def _template_data(self, context: HTTPContext) ->
|
|
251
|
+
def _template_data(self, context: HTTPContext) -> dict[str, Any]:
|
|
204
252
|
data = context.model_dump(exclude_none=True)
|
|
205
253
|
data.update(context.template_values)
|
|
206
254
|
return data
|
|
207
255
|
|
|
208
|
-
def _render(self, value: Any, data:
|
|
256
|
+
def _render(self, value: Any, data: dict[str, Any]) -> Any:
|
|
209
257
|
if isinstance(value, str):
|
|
210
258
|
return Environment().from_string(value).render(**data)
|
|
211
259
|
return value
|
|
212
260
|
|
|
213
|
-
def _render_mapping(self, values:
|
|
261
|
+
def _render_mapping(self, values: dict[str, Any], data: dict[str, Any]) -> dict[str, Any]:
|
|
214
262
|
return {key: self._render(value, data) for key, value in values.items() if value is not None}
|
|
215
263
|
|
|
216
|
-
def _render_required_auth_value(self, value:
|
|
264
|
+
def _render_required_auth_value(self, value: str | None, field_name: str, data: dict[str, Any]) -> str:
|
|
217
265
|
if value is None:
|
|
218
266
|
raise ValueError(f"HTTP auth strategy {self.auth.strategy!r} requires {field_name}.")
|
|
219
267
|
return str(self._render(value, data))
|
|
220
268
|
|
|
221
|
-
def _apply_auth(self, headers:
|
|
269
|
+
def _apply_auth(self, headers: dict[str, str], query: dict[str, Any], data: dict[str, Any]) -> None:
|
|
222
270
|
match self.auth.strategy:
|
|
223
271
|
case "none":
|
|
224
272
|
return
|
|
@@ -236,7 +284,7 @@ class HTTPModel(CallableModel):
|
|
|
236
284
|
case "basic":
|
|
237
285
|
username = self._render_required_auth_value(self.auth.username, "username", data)
|
|
238
286
|
password = self._render_required_auth_value(self.auth.password, "password", data)
|
|
239
|
-
encoded = b64encode(f"{username}:{password}".encode(
|
|
287
|
+
encoded = b64encode(f"{username}:{password}".encode()).decode("ascii")
|
|
240
288
|
headers["Authorization"] = f"Basic {encoded}"
|
|
241
289
|
case _:
|
|
242
290
|
raise ValueError(f"Unsupported HTTP auth strategy: {self.auth.strategy}")
|
|
@@ -254,7 +302,7 @@ class HTTPModel(CallableModel):
|
|
|
254
302
|
return self.config.follow_redirects
|
|
255
303
|
return self.follow_redirects
|
|
256
304
|
|
|
257
|
-
def _client_kwargs(self) ->
|
|
305
|
+
def _client_kwargs(self) -> dict[str, Any]:
|
|
258
306
|
kwargs = {
|
|
259
307
|
"base_url": self._base_url(),
|
|
260
308
|
"timeout": self._timeout(),
|
|
@@ -264,7 +312,7 @@ class HTTPModel(CallableModel):
|
|
|
264
312
|
kwargs["transport"] = self.config.transport
|
|
265
313
|
return kwargs
|
|
266
314
|
|
|
267
|
-
def build_request(self, context:
|
|
315
|
+
def build_request(self, context: HTTPContext | None = None) -> HTTPRequest:
|
|
268
316
|
context = context or HTTPRequestContext()
|
|
269
317
|
data = self._template_data(context)
|
|
270
318
|
|
|
@@ -286,7 +334,7 @@ class HTTPModel(CallableModel):
|
|
|
286
334
|
content=context.content if context.content is not None else self.content,
|
|
287
335
|
)
|
|
288
336
|
|
|
289
|
-
def _response_value(self, response:
|
|
337
|
+
def _response_value(self, response: httpx2.Response) -> Any:
|
|
290
338
|
match self.response_format:
|
|
291
339
|
case "json":
|
|
292
340
|
return response.json()
|
|
@@ -304,7 +352,7 @@ class HTTPModel(CallableModel):
|
|
|
304
352
|
def _safe_url(self, request: HTTPRequest) -> str:
|
|
305
353
|
return request.url.split("?", 1)[0]
|
|
306
354
|
|
|
307
|
-
def _rate_limit_headers(self, headers:
|
|
355
|
+
def _rate_limit_headers(self, headers: dict[str, str]) -> dict[str, str]:
|
|
308
356
|
rate_limit = {}
|
|
309
357
|
for key, value in headers.items():
|
|
310
358
|
normalized_key = key.lower()
|
|
@@ -322,7 +370,7 @@ class HTTPModel(CallableModel):
|
|
|
322
370
|
def _now(self) -> float:
|
|
323
371
|
return monotonic()
|
|
324
372
|
|
|
325
|
-
def _throttle(self, previous_started_at:
|
|
373
|
+
def _throttle(self, previous_started_at: float | None) -> float:
|
|
326
374
|
now = self._now()
|
|
327
375
|
if self.execution_policy is None:
|
|
328
376
|
return now
|
|
@@ -331,10 +379,10 @@ class HTTPModel(CallableModel):
|
|
|
331
379
|
self._sleep(delay_seconds)
|
|
332
380
|
return now + delay_seconds
|
|
333
381
|
|
|
334
|
-
def _retry_event(self, **values: Any) ->
|
|
382
|
+
def _retry_event(self, **values: Any) -> dict[str, Any]:
|
|
335
383
|
return HTTPRetryEvent(**values).model_dump(exclude={"type_"}, exclude_none=True)
|
|
336
384
|
|
|
337
|
-
def _retry_summary(self, events:
|
|
385
|
+
def _retry_summary(self, events: list[dict[str, Any]], attempts: int, succeeded: bool) -> dict[str, int]:
|
|
338
386
|
return {
|
|
339
387
|
"attempts": attempts,
|
|
340
388
|
"retried": sum(1 for event in events if event["outcome"] == "retry"),
|
|
@@ -351,10 +399,10 @@ class HTTPModel(CallableModel):
|
|
|
351
399
|
return current
|
|
352
400
|
|
|
353
401
|
def _request_once(
|
|
354
|
-
self, client:
|
|
355
|
-
) ->
|
|
402
|
+
self, client: httpx2.Client, request: HTTPRequest, previous_started_at: float | None
|
|
403
|
+
) -> tuple[httpx2.Response, int, list[dict[str, Any]], float]:
|
|
356
404
|
attempts = 0
|
|
357
|
-
events:
|
|
405
|
+
events: list[dict[str, Any]] = []
|
|
358
406
|
retry_policy = self._retry_policy()
|
|
359
407
|
total_retry_wait_seconds = 0.0
|
|
360
408
|
while True:
|
|
@@ -369,13 +417,18 @@ class HTTPModel(CallableModel):
|
|
|
369
417
|
json=request.json_data,
|
|
370
418
|
content=request.content,
|
|
371
419
|
)
|
|
420
|
+
if response.status_code in self.accepted_status_codes:
|
|
421
|
+
return response, attempts, events, previous_started_at
|
|
372
422
|
response.raise_for_status()
|
|
373
423
|
return response, attempts, events, previous_started_at
|
|
374
|
-
except
|
|
424
|
+
except httpx2.HTTPStatusError as exc:
|
|
375
425
|
status_code = exc.response.status_code if exc.response is not None else None
|
|
376
426
|
if retry_policy.should_retry_status(status_code, attempts):
|
|
377
427
|
delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
|
|
378
428
|
if delay_seconds is not None:
|
|
429
|
+
retry_after = retry_policy.retry_after_seconds(dict(exc.response.headers or {})) if exc.response is not None else None
|
|
430
|
+
if retry_after is not None:
|
|
431
|
+
delay_seconds = max(delay_seconds, retry_after)
|
|
379
432
|
events.append(
|
|
380
433
|
self._retry_event(
|
|
381
434
|
attempt=attempts,
|
|
@@ -399,8 +452,9 @@ class HTTPModel(CallableModel):
|
|
|
399
452
|
)
|
|
400
453
|
)
|
|
401
454
|
status_label = status_code if status_code is not None else "unknown"
|
|
402
|
-
|
|
403
|
-
|
|
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:
|
|
404
458
|
if retry_policy.should_retry_exception(exc, attempts):
|
|
405
459
|
delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
|
|
406
460
|
if delay_seconds is not None:
|
|
@@ -427,10 +481,10 @@ class HTTPModel(CallableModel):
|
|
|
427
481
|
)
|
|
428
482
|
)
|
|
429
483
|
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
|
|
430
|
-
except
|
|
484
|
+
except httpx2.HTTPError as exc:
|
|
431
485
|
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
|
|
432
486
|
|
|
433
|
-
def _merge_page_values(self, values:
|
|
487
|
+
def _merge_page_values(self, values: list[Any]) -> Any:
|
|
434
488
|
if not values:
|
|
435
489
|
return []
|
|
436
490
|
if all(isinstance(value, dict) and isinstance(value.get(self.results_field), list) for value in values):
|
|
@@ -441,22 +495,18 @@ class HTTPModel(CallableModel):
|
|
|
441
495
|
return merged
|
|
442
496
|
return values
|
|
443
497
|
|
|
444
|
-
def _page_items(self, value: Any) ->
|
|
498
|
+
def _page_items(self, value: Any) -> list[Any] | None:
|
|
445
499
|
items = self._extract_field(value, self.results_field)
|
|
446
500
|
return items if isinstance(items, list) else None
|
|
447
501
|
|
|
448
|
-
def _request_with_params(self, request: HTTPRequest, params:
|
|
502
|
+
def _request_with_params(self, request: HTTPRequest, params: dict[str, Any]) -> HTTPRequest:
|
|
449
503
|
return request.model_copy(update={"params": {**request.params, **params}})
|
|
450
504
|
|
|
451
|
-
def _is_sensitive_query_param(self, key: str) -> bool:
|
|
452
|
-
normalized_key = key.lower().replace("_", "").replace("-", "")
|
|
453
|
-
return normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
|
|
454
|
-
|
|
455
505
|
def _next_url_request(self, request: HTTPRequest, next_url: str) -> HTTPRequest:
|
|
456
506
|
next_url_parts = urlsplit(next_url)
|
|
457
507
|
next_url_params = dict(parse_qsl(next_url_parts.query, keep_blank_values=True))
|
|
458
508
|
for key, value in request.params.items():
|
|
459
|
-
if key not in next_url_params and
|
|
509
|
+
if key not in next_url_params and _is_sensitive_key(key):
|
|
460
510
|
next_url_params[key] = value
|
|
461
511
|
next_url_without_query = urlunsplit((next_url_parts.scheme, next_url_parts.netloc, next_url_parts.path, "", next_url_parts.fragment))
|
|
462
512
|
return request.model_copy(update={"url": next_url_without_query, "params": next_url_params})
|
|
@@ -484,7 +534,7 @@ class HTTPModel(CallableModel):
|
|
|
484
534
|
case _:
|
|
485
535
|
raise ValueError(f"Unsupported pagination mode: {self.pagination_mode}")
|
|
486
536
|
|
|
487
|
-
def _next_paginated_request(self, request: HTTPRequest, value: Any) ->
|
|
537
|
+
def _next_paginated_request(self, request: HTTPRequest, value: Any) -> HTTPRequest | None:
|
|
488
538
|
if self._page_items(value) == []:
|
|
489
539
|
return None
|
|
490
540
|
match self.pagination_mode:
|
|
@@ -508,7 +558,7 @@ class HTTPModel(CallableModel):
|
|
|
508
558
|
def __call__(self, context: HTTPRequestContext) -> HTTPResult:
|
|
509
559
|
request = self._initial_paginated_request(self.build_request(context))
|
|
510
560
|
|
|
511
|
-
with
|
|
561
|
+
with httpx2.Client(**self._client_kwargs()) as client:
|
|
512
562
|
values = []
|
|
513
563
|
retry_events = []
|
|
514
564
|
total_attempts = 0
|
|
@@ -519,6 +569,9 @@ class HTTPModel(CallableModel):
|
|
|
519
569
|
total_attempts += attempts
|
|
520
570
|
retry_events.extend(events)
|
|
521
571
|
pages += 1
|
|
572
|
+
accepted_error = response.status_code in self.accepted_status_codes
|
|
573
|
+
if accepted_error:
|
|
574
|
+
break
|
|
522
575
|
value = self._response_value(response)
|
|
523
576
|
values.append(value)
|
|
524
577
|
|
|
@@ -530,10 +583,10 @@ class HTTPModel(CallableModel):
|
|
|
530
583
|
request = next_request
|
|
531
584
|
|
|
532
585
|
return HTTPResult(
|
|
533
|
-
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]),
|
|
534
587
|
status_code=response.status_code,
|
|
535
588
|
headers=dict(response.headers or {}),
|
|
536
|
-
url=str(response.url),
|
|
589
|
+
url=redact_url(str(response.url)),
|
|
537
590
|
attempts=total_attempts,
|
|
538
591
|
pages=pages,
|
|
539
592
|
rate_limit=self._rate_limit_headers(dict(response.headers or {})),
|
|
@@ -2,14 +2,25 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from base64 import b64decode
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC
|
|
5
6
|
from gzip import compress
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
|
-
import
|
|
9
|
+
import httpx2
|
|
9
10
|
import pytest
|
|
10
11
|
from ccflow_etl import ExecutionPolicy
|
|
11
12
|
|
|
12
|
-
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
|
+
)
|
|
13
24
|
|
|
14
25
|
|
|
15
26
|
@dataclass
|
|
@@ -51,7 +62,7 @@ def test_http_model_renders_request_and_returns_json(monkeypatch):
|
|
|
51
62
|
calls.append({"client": self.kwargs, "request": kwargs})
|
|
52
63
|
return FakeResponse(value={"status": "OK", "results": [{"ticker": "AAA"}]}, headers={"x-limit-remaining": "9"})
|
|
53
64
|
|
|
54
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
65
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
55
66
|
|
|
56
67
|
model = HTTPModel(
|
|
57
68
|
base_url="https://api.example.test",
|
|
@@ -132,11 +143,11 @@ def test_http_model_error_message_omits_secret_query_values(monkeypatch):
|
|
|
132
143
|
return False
|
|
133
144
|
|
|
134
145
|
def request(self, **kwargs):
|
|
135
|
-
request =
|
|
136
|
-
response =
|
|
137
|
-
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)
|
|
138
149
|
|
|
139
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
150
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
140
151
|
|
|
141
152
|
model = HTTPModel(base_url="https://api.example.test", path="/v1/tickers/{{ ticker }}", query={"apiKey": "{{ api_key }}"})
|
|
142
153
|
|
|
@@ -145,6 +156,34 @@ def test_http_model_error_message_omits_secret_query_values(monkeypatch):
|
|
|
145
156
|
|
|
146
157
|
assert "secret" not in str(error.value)
|
|
147
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=***"
|
|
148
187
|
|
|
149
188
|
|
|
150
189
|
def test_http_model_retries_retryable_status_and_captures_rate_limit(monkeypatch):
|
|
@@ -163,12 +202,12 @@ def test_http_model_retries_retryable_status_and_captures_rate_limit(monkeypatch
|
|
|
163
202
|
def request(self, **kwargs):
|
|
164
203
|
calls.append(kwargs)
|
|
165
204
|
if len(calls) == 1:
|
|
166
|
-
request =
|
|
167
|
-
response =
|
|
168
|
-
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)
|
|
169
208
|
return FakeResponse(value={"status": "OK"}, headers={"x-ratelimit-remaining": "8"})
|
|
170
209
|
|
|
171
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
210
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
172
211
|
|
|
173
212
|
result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
|
|
174
213
|
|
|
@@ -194,12 +233,12 @@ def test_http_model_consumes_shared_retry_policy(monkeypatch):
|
|
|
194
233
|
def request(self, **kwargs):
|
|
195
234
|
calls.append(kwargs)
|
|
196
235
|
if len(calls) == 1:
|
|
197
|
-
request =
|
|
198
|
-
response =
|
|
199
|
-
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)
|
|
200
239
|
return FakeResponse(value={"status": "OK"})
|
|
201
240
|
|
|
202
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
241
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
203
242
|
|
|
204
243
|
model = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", retry_policy=HTTPRetryPolicy(max_attempts=2, retry_status_codes=[503]))
|
|
205
244
|
|
|
@@ -222,12 +261,12 @@ def test_http_model_consumes_shared_retry_delay_and_execution_policy(monkeypatch
|
|
|
222
261
|
def request(self, **kwargs):
|
|
223
262
|
calls.append(kwargs)
|
|
224
263
|
if len(calls) == 1:
|
|
225
|
-
request =
|
|
226
|
-
response =
|
|
227
|
-
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)
|
|
228
267
|
return FakeResponse(value={"status": "OK"})
|
|
229
268
|
|
|
230
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
269
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
231
270
|
|
|
232
271
|
model = HTTPModel(
|
|
233
272
|
base_url="https://api.example.test",
|
|
@@ -273,10 +312,10 @@ def test_http_model_retries_timeout_exception(monkeypatch):
|
|
|
273
312
|
def request(self, **kwargs):
|
|
274
313
|
calls.append(kwargs)
|
|
275
314
|
if len(calls) == 1:
|
|
276
|
-
raise
|
|
315
|
+
raise httpx2.TimeoutException("timed out")
|
|
277
316
|
return FakeResponse(value={"status": "OK"})
|
|
278
317
|
|
|
279
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
318
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
280
319
|
|
|
281
320
|
result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
|
|
282
321
|
|
|
@@ -300,11 +339,11 @@ def test_http_model_retries_5xx_until_attempts_are_exhausted(monkeypatch):
|
|
|
300
339
|
|
|
301
340
|
def request(self, **kwargs):
|
|
302
341
|
calls.append(kwargs)
|
|
303
|
-
request =
|
|
304
|
-
response =
|
|
305
|
-
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)
|
|
306
345
|
|
|
307
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
346
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
308
347
|
|
|
309
348
|
model = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", query={"apiKey": "secret"}, max_attempts=2)
|
|
310
349
|
|
|
@@ -335,7 +374,7 @@ def test_http_model_paginates_massive_style_next_url(monkeypatch):
|
|
|
335
374
|
return FakeResponse(value={"results": [{"ticker": "AAA"}], "next_url": "/v3/reference/tickers?cursor=2"})
|
|
336
375
|
return FakeResponse(value={"results": [{"ticker": "BBB"}]})
|
|
337
376
|
|
|
338
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
377
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
339
378
|
|
|
340
379
|
result = HTTPModel(base_url="https://api.example.test", path="/v3/reference/tickers", paginate=True)(HTTPRequestContext())
|
|
341
380
|
|
|
@@ -364,7 +403,7 @@ def test_http_model_preserves_query_auth_for_next_url_pagination(monkeypatch):
|
|
|
364
403
|
return FakeResponse(value={"results": [{"ticker": "AAA"}], "next_url": "/v3/reference/tickers?cursor=2"})
|
|
365
404
|
return FakeResponse(value={"results": [{"ticker": "BBB"}]})
|
|
366
405
|
|
|
367
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
406
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", FakeClient)
|
|
368
407
|
|
|
369
408
|
HTTPModel(
|
|
370
409
|
base_url="https://api.example.test",
|
|
@@ -377,17 +416,17 @@ def test_http_model_preserves_query_auth_for_next_url_pagination(monkeypatch):
|
|
|
377
416
|
assert calls[1]["params"] == {"cursor": "2", "apiKey": "secret"}
|
|
378
417
|
|
|
379
418
|
|
|
380
|
-
def
|
|
419
|
+
def test_http_model_sends_next_url_query_and_preserved_auth_with_httpx2_transport():
|
|
381
420
|
seen_urls = []
|
|
382
421
|
|
|
383
|
-
def handler(request:
|
|
422
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
384
423
|
seen_urls.append(str(request.url))
|
|
385
424
|
if len(seen_urls) == 1:
|
|
386
|
-
return
|
|
387
|
-
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"}]})
|
|
388
427
|
|
|
389
428
|
model = HTTPModel(
|
|
390
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
429
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
391
430
|
path="/v3/reference/tickers",
|
|
392
431
|
query={"apiKey": "secret"},
|
|
393
432
|
paginate=True,
|
|
@@ -405,11 +444,11 @@ def test_http_model_sends_next_url_query_and_preserved_auth_with_httpx_transport
|
|
|
405
444
|
def test_http_model_applies_config_and_all_auth_strategies_with_mock_transport():
|
|
406
445
|
seen_requests = []
|
|
407
446
|
|
|
408
|
-
def handler(request:
|
|
447
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
409
448
|
seen_requests.append(request)
|
|
410
|
-
return
|
|
449
|
+
return httpx2.Response(200, json={"status": "OK"}, headers={"x-ratelimit-remaining": "7"})
|
|
411
450
|
|
|
412
|
-
transport =
|
|
451
|
+
transport = httpx2.MockTransport(handler)
|
|
413
452
|
config = HTTPConfig(base_url="https://api.example.test", timeout=4.0, headers={"Accept": "application/json"}, transport=transport)
|
|
414
453
|
|
|
415
454
|
bearer = HTTPModel(config=config, path="/v1/{{ resource }}", auth=HTTPAuth(strategy="bearer", token="{{ token }}"))
|
|
@@ -438,21 +477,54 @@ def test_http_model_applies_config_and_all_auth_strategies_with_mock_transport()
|
|
|
438
477
|
assert "authorization" not in seen_requests[4].headers
|
|
439
478
|
|
|
440
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
|
+
|
|
441
513
|
def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
|
|
442
|
-
def csv_handler(request:
|
|
443
|
-
return
|
|
514
|
+
def csv_handler(request: httpx2.Request) -> httpx2.Response:
|
|
515
|
+
return httpx2.Response(200, text="ticker,volume\nAAA,10\nBBB,20\n")
|
|
444
516
|
|
|
445
517
|
csv_result = HTTPModel(
|
|
446
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
518
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(csv_handler)),
|
|
447
519
|
path="/csv",
|
|
448
520
|
response_format="csv",
|
|
449
521
|
)(HTTPRequestContext())
|
|
450
522
|
|
|
451
|
-
def gzip_handler(request:
|
|
452
|
-
return
|
|
523
|
+
def gzip_handler(request: httpx2.Request) -> httpx2.Response:
|
|
524
|
+
return httpx2.Response(200, content=compress(b'{"status":"OK"}'))
|
|
453
525
|
|
|
454
526
|
gzip_result = HTTPModel(
|
|
455
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
527
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(gzip_handler)),
|
|
456
528
|
path="/gzip",
|
|
457
529
|
response_format="gzip",
|
|
458
530
|
)(HTTPRequestContext())
|
|
@@ -464,14 +536,14 @@ def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
|
|
|
464
536
|
def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transport():
|
|
465
537
|
cursor_requests = []
|
|
466
538
|
|
|
467
|
-
def cursor_handler(request:
|
|
539
|
+
def cursor_handler(request: httpx2.Request) -> httpx2.Response:
|
|
468
540
|
cursor_requests.append(dict(request.url.params))
|
|
469
541
|
if "cursor" not in request.url.params:
|
|
470
|
-
return
|
|
471
|
-
return
|
|
542
|
+
return httpx2.Response(200, json={"results": [{"id": 1}], "next_cursor": "abc"})
|
|
543
|
+
return httpx2.Response(200, json={"results": [{"id": 2}]})
|
|
472
544
|
|
|
473
545
|
cursor_result = HTTPModel(
|
|
474
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
546
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(cursor_handler)),
|
|
475
547
|
path="/items",
|
|
476
548
|
paginate=True,
|
|
477
549
|
pagination_mode="cursor",
|
|
@@ -481,14 +553,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
|
|
|
481
553
|
|
|
482
554
|
page_requests = []
|
|
483
555
|
|
|
484
|
-
def page_handler(request:
|
|
556
|
+
def page_handler(request: httpx2.Request) -> httpx2.Response:
|
|
485
557
|
page_requests.append(dict(request.url.params))
|
|
486
558
|
page = int(request.url.params["page"])
|
|
487
559
|
payload = {1: [{"id": 1}], 2: [{"id": 2}]}.get(page, [])
|
|
488
|
-
return
|
|
560
|
+
return httpx2.Response(200, json={"results": payload})
|
|
489
561
|
|
|
490
562
|
page_result = HTTPModel(
|
|
491
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
563
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(page_handler)),
|
|
492
564
|
path="/items",
|
|
493
565
|
paginate=True,
|
|
494
566
|
pagination_mode="page",
|
|
@@ -499,14 +571,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
|
|
|
499
571
|
|
|
500
572
|
offset_requests = []
|
|
501
573
|
|
|
502
|
-
def offset_handler(request:
|
|
574
|
+
def offset_handler(request: httpx2.Request) -> httpx2.Response:
|
|
503
575
|
offset_requests.append(dict(request.url.params))
|
|
504
576
|
offset = int(request.url.params["offset"])
|
|
505
577
|
payload = {0: [{"id": 1}], 2: [{"id": 2}]}.get(offset, [])
|
|
506
|
-
return
|
|
578
|
+
return httpx2.Response(200, json={"results": payload})
|
|
507
579
|
|
|
508
580
|
offset_result = HTTPModel(
|
|
509
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
581
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(offset_handler)),
|
|
510
582
|
path="/items",
|
|
511
583
|
paginate=True,
|
|
512
584
|
pagination_mode="offset",
|
|
@@ -522,3 +594,137 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
|
|
|
522
594
|
assert page_result.value["results"] == [{"id": 1}, {"id": 2}]
|
|
523
595
|
assert offset_requests == [{"offset": "0", "limit": "2"}, {"offset": "2", "limit": "2"}, {"offset": "4", "limit": "2"}]
|
|
524
596
|
assert offset_result.value["results"] == [{"id": 1}, {"id": 2}]
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _retrying_client(calls, response_factory):
|
|
600
|
+
class FakeClient:
|
|
601
|
+
def __init__(self, **kwargs):
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
def __enter__(self):
|
|
605
|
+
return self
|
|
606
|
+
|
|
607
|
+
def __exit__(self, exc_type, exc, traceback):
|
|
608
|
+
return False
|
|
609
|
+
|
|
610
|
+
def request(self, **kwargs):
|
|
611
|
+
calls.append(kwargs)
|
|
612
|
+
if len(calls) == 1:
|
|
613
|
+
response_factory()
|
|
614
|
+
return FakeResponse(value={"status": "OK"})
|
|
615
|
+
|
|
616
|
+
return FakeClient
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _raise_status(status_code, headers=None):
|
|
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)
|
|
623
|
+
|
|
624
|
+
|
|
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):
|
|
627
|
+
calls = []
|
|
628
|
+
|
|
629
|
+
def fail_once():
|
|
630
|
+
raise exception_type("transport failed")
|
|
631
|
+
|
|
632
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, fail_once))
|
|
633
|
+
|
|
634
|
+
result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
|
|
635
|
+
|
|
636
|
+
assert len(calls) == 2
|
|
637
|
+
assert result.attempts == 2
|
|
638
|
+
assert result.retry_events[0]["category"] == category
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def test_http_model_honors_retry_after_seconds_over_backoff(monkeypatch):
|
|
642
|
+
calls = []
|
|
643
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "45"})))
|
|
644
|
+
|
|
645
|
+
model = HTTPModel(
|
|
646
|
+
base_url="https://api.example.test",
|
|
647
|
+
path="/v1/tickers",
|
|
648
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=1.0),
|
|
649
|
+
)
|
|
650
|
+
sleeps = []
|
|
651
|
+
monkeypatch.setattr(model, "_sleep", sleeps.append)
|
|
652
|
+
|
|
653
|
+
result = model(HTTPRequestContext())
|
|
654
|
+
|
|
655
|
+
assert len(calls) == 2
|
|
656
|
+
assert sleeps == [45.0]
|
|
657
|
+
assert result.retry_events[0]["delay_seconds"] == 45.0
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_http_model_uses_backoff_when_it_exceeds_retry_after(monkeypatch):
|
|
661
|
+
calls = []
|
|
662
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "1"})))
|
|
663
|
+
|
|
664
|
+
model = HTTPModel(
|
|
665
|
+
base_url="https://api.example.test",
|
|
666
|
+
path="/v1/tickers",
|
|
667
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=5.0),
|
|
668
|
+
)
|
|
669
|
+
sleeps = []
|
|
670
|
+
monkeypatch.setattr(model, "_sleep", sleeps.append)
|
|
671
|
+
|
|
672
|
+
model(HTTPRequestContext())
|
|
673
|
+
|
|
674
|
+
assert sleeps == [5.0]
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def test_http_model_caps_retry_after_at_retry_after_max(monkeypatch):
|
|
678
|
+
calls = []
|
|
679
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "9999"})))
|
|
680
|
+
|
|
681
|
+
model = HTTPModel(
|
|
682
|
+
base_url="https://api.example.test",
|
|
683
|
+
path="/v1/tickers",
|
|
684
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=1.0, retry_after_max=300.0),
|
|
685
|
+
)
|
|
686
|
+
sleeps = []
|
|
687
|
+
monkeypatch.setattr(model, "_sleep", sleeps.append)
|
|
688
|
+
|
|
689
|
+
model(HTTPRequestContext())
|
|
690
|
+
|
|
691
|
+
assert sleeps == [300.0]
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def test_http_model_honors_retry_after_http_date(monkeypatch):
|
|
695
|
+
from datetime import datetime, timedelta
|
|
696
|
+
from email.utils import format_datetime
|
|
697
|
+
|
|
698
|
+
calls = []
|
|
699
|
+
retry_at = format_datetime(datetime.now(UTC) + timedelta(seconds=60))
|
|
700
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": retry_at})))
|
|
701
|
+
|
|
702
|
+
model = HTTPModel(
|
|
703
|
+
base_url="https://api.example.test",
|
|
704
|
+
path="/v1/tickers",
|
|
705
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=1.0),
|
|
706
|
+
)
|
|
707
|
+
sleeps = []
|
|
708
|
+
monkeypatch.setattr(model, "_sleep", sleeps.append)
|
|
709
|
+
|
|
710
|
+
model(HTTPRequestContext())
|
|
711
|
+
|
|
712
|
+
assert len(sleeps) == 1
|
|
713
|
+
assert 50.0 <= sleeps[0] <= 60.0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def test_http_model_ignores_invalid_retry_after(monkeypatch):
|
|
717
|
+
calls = []
|
|
718
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "soon"})))
|
|
719
|
+
|
|
720
|
+
model = HTTPModel(
|
|
721
|
+
base_url="https://api.example.test",
|
|
722
|
+
path="/v1/tickers",
|
|
723
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=1.0),
|
|
724
|
+
)
|
|
725
|
+
sleeps = []
|
|
726
|
+
monkeypatch.setattr(model, "_sleep", sleeps.append)
|
|
727
|
+
|
|
728
|
+
model(HTTPRequestContext())
|
|
729
|
+
|
|
730
|
+
assert sleeps == [1.0]
|
|
@@ -12,8 +12,8 @@ authors = [
|
|
|
12
12
|
description = "ccflow models for HTTP"
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
license = { text = "Apache-2.0" }
|
|
15
|
-
version = "0.
|
|
16
|
-
requires-python = ">=3.
|
|
15
|
+
version = "0.2.0"
|
|
16
|
+
requires-python = ">=3.11"
|
|
17
17
|
keywords = []
|
|
18
18
|
|
|
19
19
|
classifiers = [
|
|
@@ -22,7 +22,6 @@ classifiers = [
|
|
|
22
22
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
23
23
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
24
24
|
"Programming Language :: Python :: 3",
|
|
25
|
-
"Programming Language :: Python :: 3.10",
|
|
26
25
|
"Programming Language :: Python :: 3.11",
|
|
27
26
|
"Programming Language :: Python :: 3.12",
|
|
28
27
|
"Programming Language :: Python :: 3.13",
|
|
@@ -32,7 +31,7 @@ classifiers = [
|
|
|
32
31
|
dependencies = [
|
|
33
32
|
"ccflow>=0.8.5",
|
|
34
33
|
"ccflow-etl>=0.1.0",
|
|
35
|
-
"
|
|
34
|
+
"httpx2",
|
|
36
35
|
"jinja2",
|
|
37
36
|
]
|
|
38
37
|
|
|
@@ -61,7 +60,7 @@ Repository = "https://github.com/1kbgz/ccflow-http"
|
|
|
61
60
|
Homepage = "https://github.com/1kbgz/ccflow-http"
|
|
62
61
|
|
|
63
62
|
[tool.bumpversion]
|
|
64
|
-
current_version = "0.
|
|
63
|
+
current_version = "0.2.0"
|
|
65
64
|
commit = true
|
|
66
65
|
tag = true
|
|
67
66
|
commit_args = "-s"
|
|
File without changes
|