ccflow-http 0.1.1__tar.gz → 0.3.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.3.0}/.gitignore +5 -0
- {ccflow_http-0.1.1 → ccflow_http-0.3.0}/PKG-INFO +7 -6
- {ccflow_http-0.1.1 → ccflow_http-0.3.0}/README.md +2 -2
- ccflow_http-0.3.0/ccflow_http/__init__.py +4 -0
- {ccflow_http-0.1.1 → ccflow_http-0.3.0}/ccflow_http/base.py +135 -73
- ccflow_http-0.3.0/ccflow_http/download.py +205 -0
- ccflow_http-0.3.0/ccflow_http/tests/test_download.py +110 -0
- {ccflow_http-0.1.1 → ccflow_http-0.3.0}/ccflow_http/tests/test_model.py +204 -63
- {ccflow_http-0.1.1 → ccflow_http-0.3.0}/pyproject.toml +6 -5
- ccflow_http-0.1.1/ccflow_http/__init__.py +0 -3
- {ccflow_http-0.1.1 → ccflow_http-0.3.0}/LICENSE +0 -0
- {ccflow_http-0.1.1 → ccflow_http-0.3.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.3.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
|
|
@@ -17,9 +17,9 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
17
17
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
18
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
19
19
|
Requires-Python: >=3.11
|
|
20
|
-
Requires-Dist: ccflow-etl>=0.
|
|
20
|
+
Requires-Dist: ccflow-etl>=0.4.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'
|
|
@@ -28,6 +28,7 @@ Requires-Dist: check-dist; extra == 'develop'
|
|
|
28
28
|
Requires-Dist: codespell; extra == 'develop'
|
|
29
29
|
Requires-Dist: hatchling; extra == 'develop'
|
|
30
30
|
Requires-Dist: mdformat; extra == 'develop'
|
|
31
|
+
Requires-Dist: mdformat-frontmatter; extra == 'develop'
|
|
31
32
|
Requires-Dist: mdformat-tables>=1; extra == 'develop'
|
|
32
33
|
Requires-Dist: pytest; extra == 'develop'
|
|
33
34
|
Requires-Dist: pytest-cov; extra == 'develop'
|
|
@@ -55,7 +56,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
55
56
|
|
|
56
57
|
## Current Status
|
|
57
58
|
|
|
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 `
|
|
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, 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 (including the configured `api_key_query` parameter) from `httpx2` request logs, request dumps, result URLs, retry events, and errors, `HTTPDownloadModel` for streaming a response to a local file with size and SHA-256, `HTTPArtifactCaptureModel` for storing a dated download plus a manifest through a `ccflow-etl` file writer, and mocked `httpx2` transport tests.
|
|
59
60
|
- 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
61
|
- Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
|
|
61
62
|
|
|
@@ -67,7 +68,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
|
|
|
67
68
|
|
|
68
69
|
## Test Convention
|
|
69
70
|
|
|
70
|
-
Default tests should use mocked `
|
|
71
|
+
Default tests should use mocked `httpx2` transports or local fixtures. They should not require live network calls or provider credentials.
|
|
71
72
|
|
|
72
73
|
> [!NOTE]
|
|
73
74
|
> 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 (including the configured `api_key_query` parameter) from `httpx2` request logs, request dumps, result URLs, retry events, and errors, `HTTPDownloadModel` for streaming a response to a local file with size and SHA-256, `HTTPArtifactCaptureModel` for storing a dated download plus a manifest through a `ccflow-etl` file writer, 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,19 +1,23 @@
|
|
|
1
|
+
import re
|
|
1
2
|
from base64 import b64encode
|
|
3
|
+
from collections.abc import Collection
|
|
4
|
+
from contextvars import ContextVar
|
|
2
5
|
from csv import DictReader
|
|
3
6
|
from datetime import UTC, datetime
|
|
4
7
|
from email.utils import parsedate_to_datetime
|
|
5
8
|
from gzip import decompress
|
|
6
9
|
from io import StringIO
|
|
10
|
+
from logging import Filter, LogRecord, getLogger
|
|
7
11
|
from time import monotonic, sleep
|
|
8
12
|
from typing import Any, Literal
|
|
9
|
-
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
|
13
|
+
from urllib.parse import parse_qsl, unquote_plus, urlsplit, urlunsplit
|
|
10
14
|
|
|
11
|
-
import
|
|
15
|
+
import httpx2
|
|
12
16
|
from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, PyObjectPath
|
|
13
17
|
from ccflow.utils.retry import RetryPolicy
|
|
14
18
|
from ccflow_etl import ExecutionPolicy
|
|
15
19
|
from jinja2 import Environment
|
|
16
|
-
from pydantic import Field
|
|
20
|
+
from pydantic import Field, PrivateAttr
|
|
17
21
|
|
|
18
22
|
__all__ = (
|
|
19
23
|
"HTTPAuth",
|
|
@@ -26,6 +30,7 @@ __all__ = (
|
|
|
26
30
|
"HTTPResult",
|
|
27
31
|
"HTTPRetryPolicy",
|
|
28
32
|
"redact_mapping",
|
|
33
|
+
"redact_url",
|
|
29
34
|
"safe_request_dump",
|
|
30
35
|
)
|
|
31
36
|
|
|
@@ -73,22 +78,56 @@ class HTTPRequest(BaseModel):
|
|
|
73
78
|
headers: dict[str, str] = Field(default_factory=dict)
|
|
74
79
|
json_data: Any | None = None
|
|
75
80
|
content: bytes | str | None = None
|
|
81
|
+
# Query parameter names that carry credentials under names the key heuristic may not recognize, such as api_key_query auth.
|
|
82
|
+
_sensitive_params: frozenset[str] = PrivateAttr(default_factory=frozenset)
|
|
76
83
|
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
# Extra sensitive query names for the request in flight, so the httpx2 log filter can redact them.
|
|
86
|
+
_REQUEST_SENSITIVE_PARAMS: ContextVar[frozenset[str]] = ContextVar("ccflow_http_request_sensitive_params", default=frozenset())
|
|
87
|
+
_QUERY_PAIR = re.compile(r"([?&])([^=&\s'\"]+)=([^&\s'\"]*)")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_sensitive_key(key: str, extra: Collection[str] = ()) -> bool:
|
|
91
|
+
normalized_key = key.lower().replace("_", "").replace("-", "")
|
|
92
|
+
return key in extra or normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def redact_mapping(values: dict[str, Any], extra: Collection[str] = ()) -> dict[str, Any]:
|
|
96
|
+
return {key: "***" if _is_sensitive_key(key, extra) else value for key, value in values.items()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def redact_url(url: str, extra: Collection[str] = ()) -> str:
|
|
100
|
+
parts = urlsplit(url)
|
|
101
|
+
if not parts.query:
|
|
102
|
+
return url
|
|
103
|
+
pairs = []
|
|
104
|
+
for pair in parts.query.split("&"):
|
|
105
|
+
key = pair.split("=", 1)[0]
|
|
106
|
+
pairs.append(f"{key}=***" if _is_sensitive_key(unquote_plus(key), extra) else pair)
|
|
107
|
+
return urlunsplit(parts._replace(query="&".join(pairs)))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _redact_query_text(text: str, extra: Collection[str] = ()) -> str:
|
|
111
|
+
"""Redact secret query values that appear anywhere in free text, such as an exception message quoting a URL."""
|
|
112
|
+
return _QUERY_PAIR.sub(lambda match: f"{match[1]}{match[2]}=***" if _is_sensitive_key(unquote_plus(match[2]), extra) else match[0], text)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _RedactURLFilter(Filter):
|
|
116
|
+
"""Redact secret query values from URLs that httpx2 logs for each request."""
|
|
117
|
+
|
|
118
|
+
def filter(self, record: LogRecord) -> bool:
|
|
119
|
+
if isinstance(record.args, tuple):
|
|
120
|
+
extra = _REQUEST_SENSITIVE_PARAMS.get()
|
|
121
|
+
record.args = tuple(redact_url(str(arg), extra) if isinstance(arg, httpx2.URL) else arg for arg in record.args)
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
getLogger("httpx2").addFilter(_RedactURLFilter())
|
|
87
126
|
|
|
88
127
|
|
|
89
128
|
def safe_request_dump(request: HTTPRequest) -> dict[str, Any]:
|
|
90
129
|
request_data = request.model_dump(exclude={"type_"})
|
|
91
|
-
request_data["params"] = redact_mapping(request_data.get("params", {}))
|
|
130
|
+
request_data["params"] = redact_mapping(request_data.get("params", {}), request._sensitive_params)
|
|
92
131
|
request_data["headers"] = redact_mapping(request_data.get("headers", {}))
|
|
93
132
|
return request_data
|
|
94
133
|
|
|
@@ -105,9 +144,7 @@ class HTTPRetryEvent(BaseModel):
|
|
|
105
144
|
|
|
106
145
|
class HTTPRetryPolicy(RetryPolicy):
|
|
107
146
|
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
|
-
)
|
|
147
|
+
retry_exceptions: list[PyObjectPath] = Field(default_factory=lambda: [PyObjectPath.validate(httpx2.TransportError)])
|
|
111
148
|
timeout_exception_types: list[str] = Field(
|
|
112
149
|
default_factory=lambda: ["TimeoutError", "TimeoutException", "ConnectTimeout", "ReadTimeout", "WriteTimeout", "PoolTimeout"]
|
|
113
150
|
)
|
|
@@ -199,6 +236,10 @@ class HTTPModel(CallableModel):
|
|
|
199
236
|
max_attempts: int = 1
|
|
200
237
|
retry_status_codes: list[int] = Field(default_factory=lambda: [429, 500, 502, 503, 504])
|
|
201
238
|
retry_policy: HTTPRetryPolicy | None = None
|
|
239
|
+
accepted_status_codes: list[int] = Field(
|
|
240
|
+
default_factory=list,
|
|
241
|
+
description="Error status codes returned as results with value None instead of raising, such as 404 for lookups that may miss.",
|
|
242
|
+
)
|
|
202
243
|
execution_policy: ExecutionPolicy | None = None
|
|
203
244
|
paginate: bool = False
|
|
204
245
|
max_pages: int = 100
|
|
@@ -300,7 +341,7 @@ class HTTPModel(CallableModel):
|
|
|
300
341
|
rendered_headers = self._render_mapping(headers, data)
|
|
301
342
|
self._apply_auth(rendered_headers, rendered_query, data)
|
|
302
343
|
|
|
303
|
-
|
|
344
|
+
request = HTTPRequest(
|
|
304
345
|
method=self.method,
|
|
305
346
|
url=self._render(path, data),
|
|
306
347
|
params=rendered_query,
|
|
@@ -308,8 +349,11 @@ class HTTPModel(CallableModel):
|
|
|
308
349
|
json_data=context.json_body if context.json_body is not None else self.json_body,
|
|
309
350
|
content=context.content if context.content is not None else self.content,
|
|
310
351
|
)
|
|
352
|
+
if self.auth.strategy == "api_key_query":
|
|
353
|
+
request._sensitive_params = frozenset({self._render_required_auth_value(self.auth.name, "name", data)})
|
|
354
|
+
return request
|
|
311
355
|
|
|
312
|
-
def _response_value(self, response:
|
|
356
|
+
def _response_value(self, response: httpx2.Response) -> Any:
|
|
313
357
|
match self.response_format:
|
|
314
358
|
case "json":
|
|
315
359
|
return response.json()
|
|
@@ -373,9 +417,19 @@ class HTTPModel(CallableModel):
|
|
|
373
417
|
current = current.get(part)
|
|
374
418
|
return current
|
|
375
419
|
|
|
420
|
+
def _send(self, client: httpx2.Client, request: HTTPRequest) -> httpx2.Response:
|
|
421
|
+
return client.request(
|
|
422
|
+
method=request.method,
|
|
423
|
+
url=request.url,
|
|
424
|
+
params=request.params,
|
|
425
|
+
headers=request.headers,
|
|
426
|
+
json=request.json_data,
|
|
427
|
+
content=request.content,
|
|
428
|
+
)
|
|
429
|
+
|
|
376
430
|
def _request_once(
|
|
377
|
-
self, client:
|
|
378
|
-
) -> tuple[
|
|
431
|
+
self, client: httpx2.Client, request: HTTPRequest, previous_started_at: float | None
|
|
432
|
+
) -> tuple[httpx2.Response, int, list[dict[str, Any]], float]:
|
|
379
433
|
attempts = 0
|
|
380
434
|
events: list[dict[str, Any]] = []
|
|
381
435
|
retry_policy = self._retry_policy()
|
|
@@ -384,17 +438,14 @@ class HTTPModel(CallableModel):
|
|
|
384
438
|
attempts += 1
|
|
385
439
|
previous_started_at = self._throttle(previous_started_at)
|
|
386
440
|
try:
|
|
387
|
-
response = client
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
params=request.params,
|
|
391
|
-
headers=request.headers,
|
|
392
|
-
json=request.json_data,
|
|
393
|
-
content=request.content,
|
|
394
|
-
)
|
|
441
|
+
response = self._send(client, request)
|
|
442
|
+
if response.status_code in self.accepted_status_codes:
|
|
443
|
+
return response, attempts, events, previous_started_at
|
|
395
444
|
response.raise_for_status()
|
|
396
445
|
return response, attempts, events, previous_started_at
|
|
397
|
-
except
|
|
446
|
+
except httpx2.HTTPStatusError as exc:
|
|
447
|
+
if exc.response is not None:
|
|
448
|
+
exc.response.close()
|
|
398
449
|
status_code = exc.response.status_code if exc.response is not None else None
|
|
399
450
|
if retry_policy.should_retry_status(status_code, attempts):
|
|
400
451
|
delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
|
|
@@ -425,8 +476,9 @@ class HTTPModel(CallableModel):
|
|
|
425
476
|
)
|
|
426
477
|
)
|
|
427
478
|
status_label = status_code if status_code is not None else "unknown"
|
|
428
|
-
|
|
429
|
-
|
|
479
|
+
# httpx2 status errors embed the full request URL, including secret query values.
|
|
480
|
+
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with status {status_label}") from None
|
|
481
|
+
except httpx2.TransportError as exc:
|
|
430
482
|
if retry_policy.should_retry_exception(exc, attempts):
|
|
431
483
|
delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
|
|
432
484
|
if delay_seconds is not None:
|
|
@@ -437,7 +489,7 @@ class HTTPModel(CallableModel):
|
|
|
437
489
|
delay_seconds=delay_seconds,
|
|
438
490
|
exception_type=type(exc).__name__,
|
|
439
491
|
category=retry_policy.exception_category(exc),
|
|
440
|
-
message=str(exc),
|
|
492
|
+
message=_redact_query_text(str(exc), request._sensitive_params),
|
|
441
493
|
)
|
|
442
494
|
)
|
|
443
495
|
self._sleep(delay_seconds)
|
|
@@ -449,12 +501,13 @@ class HTTPModel(CallableModel):
|
|
|
449
501
|
outcome="failed",
|
|
450
502
|
exception_type=type(exc).__name__,
|
|
451
503
|
category=retry_policy.exception_category(exc),
|
|
452
|
-
message=str(exc),
|
|
504
|
+
message=_redact_query_text(str(exc), request._sensitive_params),
|
|
453
505
|
)
|
|
454
506
|
)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
507
|
+
# httpx2 exceptions keep the full request, including secret query values, so do not chain them.
|
|
508
|
+
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
|
|
509
|
+
except httpx2.HTTPError as exc:
|
|
510
|
+
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
|
|
458
511
|
|
|
459
512
|
def _merge_page_values(self, values: list[Any]) -> Any:
|
|
460
513
|
if not values:
|
|
@@ -474,15 +527,11 @@ class HTTPModel(CallableModel):
|
|
|
474
527
|
def _request_with_params(self, request: HTTPRequest, params: dict[str, Any]) -> HTTPRequest:
|
|
475
528
|
return request.model_copy(update={"params": {**request.params, **params}})
|
|
476
529
|
|
|
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
530
|
def _next_url_request(self, request: HTTPRequest, next_url: str) -> HTTPRequest:
|
|
482
531
|
next_url_parts = urlsplit(next_url)
|
|
483
532
|
next_url_params = dict(parse_qsl(next_url_parts.query, keep_blank_values=True))
|
|
484
533
|
for key, value in request.params.items():
|
|
485
|
-
if key not in next_url_params and
|
|
534
|
+
if key not in next_url_params and _is_sensitive_key(key, request._sensitive_params):
|
|
486
535
|
next_url_params[key] = value
|
|
487
536
|
next_url_without_query = urlunsplit((next_url_parts.scheme, next_url_parts.netloc, next_url_parts.path, "", next_url_parts.fragment))
|
|
488
537
|
return request.model_copy(update={"url": next_url_without_query, "params": next_url_params})
|
|
@@ -533,36 +582,49 @@ class HTTPModel(CallableModel):
|
|
|
533
582
|
@Flow.call
|
|
534
583
|
def __call__(self, context: HTTPRequestContext) -> HTTPResult:
|
|
535
584
|
request = self._initial_paginated_request(self.build_request(context))
|
|
585
|
+
sensitive_params = request._sensitive_params
|
|
536
586
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
)
|
|
587
|
+
token = _REQUEST_SENSITIVE_PARAMS.set(sensitive_params)
|
|
588
|
+
try:
|
|
589
|
+
with httpx2.Client(**self._client_kwargs()) as client:
|
|
590
|
+
values = []
|
|
591
|
+
retry_events = []
|
|
592
|
+
total_attempts = 0
|
|
593
|
+
pages = 0
|
|
594
|
+
previous_started_at = None
|
|
595
|
+
while True:
|
|
596
|
+
response, attempts, events, previous_started_at = self._request_once(client, request, previous_started_at)
|
|
597
|
+
total_attempts += attempts
|
|
598
|
+
retry_events.extend(events)
|
|
599
|
+
pages += 1
|
|
600
|
+
accepted_error = response.status_code in self.accepted_status_codes
|
|
601
|
+
if accepted_error:
|
|
602
|
+
if pages > 1:
|
|
603
|
+
raise RuntimeError(
|
|
604
|
+
f"HTTP {request.method} {self._safe_url(request)} returned status {response.status_code} on page {pages}; "
|
|
605
|
+
"accepted statuses are only returned for the first page"
|
|
606
|
+
)
|
|
607
|
+
break
|
|
608
|
+
value = self._response_value(response)
|
|
609
|
+
values.append(value)
|
|
610
|
+
|
|
611
|
+
if not self.paginate or pages >= self.max_pages:
|
|
612
|
+
break
|
|
613
|
+
next_request = self._next_paginated_request(request, value)
|
|
614
|
+
if next_request is None:
|
|
615
|
+
break
|
|
616
|
+
request = next_request
|
|
617
|
+
finally:
|
|
618
|
+
_REQUEST_SENSITIVE_PARAMS.reset(token)
|
|
619
|
+
|
|
620
|
+
return HTTPResult(
|
|
621
|
+
value=None if accepted_error else (self._merge_page_values(values) if self.paginate else values[-1]),
|
|
622
|
+
status_code=response.status_code,
|
|
623
|
+
headers=dict(response.headers or {}),
|
|
624
|
+
url=redact_url(str(response.url), sensitive_params),
|
|
625
|
+
attempts=total_attempts,
|
|
626
|
+
pages=pages,
|
|
627
|
+
rate_limit=self._rate_limit_headers(dict(response.headers or {})),
|
|
628
|
+
retry_events=retry_events,
|
|
629
|
+
retry_summary=self._retry_summary(retry_events, attempts=total_attempts, succeeded=True),
|
|
630
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import UTC, date, datetime
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
import httpx2
|
|
9
|
+
from ccflow import CallableModel, DateContext, Flow, ResultBase
|
|
10
|
+
from ccflow_etl import ArtifactWriteFileContext, ArtifactWriteFileModel, ArtifactWriteFileResult
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from .base import _REQUEST_SENSITIVE_PARAMS, HTTPModel, HTTPRequest, HTTPRequestContext, redact_url
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
"HTTPArtifactCaptureModel",
|
|
17
|
+
"HTTPArtifactCaptureResult",
|
|
18
|
+
"HTTPDownloadContext",
|
|
19
|
+
"HTTPDownloadModel",
|
|
20
|
+
"HTTPDownloadResult",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HTTPDownloadContext(HTTPRequestContext):
|
|
25
|
+
output_path: Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HTTPDownloadResult(ResultBase):
|
|
29
|
+
path: str
|
|
30
|
+
size: int
|
|
31
|
+
sha256: str
|
|
32
|
+
status_code: int
|
|
33
|
+
url: str
|
|
34
|
+
etag: str | None = None
|
|
35
|
+
last_modified: str | None = None
|
|
36
|
+
content_type: str | None = None
|
|
37
|
+
attempts: int = 1
|
|
38
|
+
retry_events: list[dict[str, Any]] = Field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HTTPDownloadModel(HTTPModel):
|
|
42
|
+
"""Stream a response body to a local file without holding it in memory.
|
|
43
|
+
|
|
44
|
+
The file is written to a temporary path beside the target and renamed into place only after the full body
|
|
45
|
+
has been received, so a failed download never leaves a partial file at the target path.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
chunk_size: int = Field(default=1 << 20, ge=1)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def context_type(self) -> type[HTTPDownloadContext]:
|
|
52
|
+
return HTTPDownloadContext
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def result_type(self) -> type[HTTPDownloadResult]:
|
|
56
|
+
return HTTPDownloadResult
|
|
57
|
+
|
|
58
|
+
def _send(self, client: httpx2.Client, request: HTTPRequest) -> httpx2.Response:
|
|
59
|
+
built = client.build_request(
|
|
60
|
+
method=request.method,
|
|
61
|
+
url=request.url,
|
|
62
|
+
params=request.params,
|
|
63
|
+
headers=request.headers,
|
|
64
|
+
json=request.json_data,
|
|
65
|
+
content=request.content,
|
|
66
|
+
)
|
|
67
|
+
return client.send(built, stream=True)
|
|
68
|
+
|
|
69
|
+
@Flow.call
|
|
70
|
+
def __call__(self, context: HTTPDownloadContext) -> HTTPDownloadResult:
|
|
71
|
+
request = self.build_request(context)
|
|
72
|
+
sensitive_params = request._sensitive_params
|
|
73
|
+
path = context.output_path
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
temp_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
|
76
|
+
digest = sha256()
|
|
77
|
+
size = 0
|
|
78
|
+
token = _REQUEST_SENSITIVE_PARAMS.set(sensitive_params)
|
|
79
|
+
try:
|
|
80
|
+
with httpx2.Client(**self._client_kwargs()) as client:
|
|
81
|
+
response, attempts, events, _ = self._request_once(client, request, None)
|
|
82
|
+
try:
|
|
83
|
+
with temp_path.open("wb") as file:
|
|
84
|
+
for chunk in response.iter_bytes(self.chunk_size):
|
|
85
|
+
file.write(chunk)
|
|
86
|
+
digest.update(chunk)
|
|
87
|
+
size += len(chunk)
|
|
88
|
+
finally:
|
|
89
|
+
response.close()
|
|
90
|
+
temp_path.replace(path)
|
|
91
|
+
except BaseException:
|
|
92
|
+
temp_path.unlink(missing_ok=True)
|
|
93
|
+
raise
|
|
94
|
+
finally:
|
|
95
|
+
_REQUEST_SENSITIVE_PARAMS.reset(token)
|
|
96
|
+
return HTTPDownloadResult(
|
|
97
|
+
path=str(path),
|
|
98
|
+
size=size,
|
|
99
|
+
sha256=digest.hexdigest(),
|
|
100
|
+
status_code=response.status_code,
|
|
101
|
+
url=redact_url(str(response.url), sensitive_params),
|
|
102
|
+
etag=response.headers.get("etag"),
|
|
103
|
+
last_modified=response.headers.get("last-modified"),
|
|
104
|
+
content_type=response.headers.get("content-type"),
|
|
105
|
+
attempts=attempts,
|
|
106
|
+
retry_events=events,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HTTPArtifactCaptureResult(ResultBase):
|
|
111
|
+
date: date
|
|
112
|
+
key: str
|
|
113
|
+
manifest_key: str
|
|
114
|
+
status: str
|
|
115
|
+
download: HTTPDownloadResult | None = None
|
|
116
|
+
write: ArtifactWriteFileResult | None = None
|
|
117
|
+
manifest_write: ArtifactWriteFileResult | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class HTTPArtifactCaptureModel(CallableModel):
|
|
121
|
+
"""Capture one HTTP resource for a date: download it, store it under a dated key, and store a manifest beside it.
|
|
122
|
+
|
|
123
|
+
The manifest records the redacted URL, capture time, status, response headers, size, and SHA-256 of the stored
|
|
124
|
+
payload. An existing payload is skipped before downloading unless ``overwrite`` is set.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
download: HTTPDownloadModel
|
|
128
|
+
writer: ArtifactWriteFileModel
|
|
129
|
+
key_template: str = Field(description="Payload key with {date}, {year}, {month}, and {day} placeholders.")
|
|
130
|
+
workspace: Path = Path("cc-etl-workspace")
|
|
131
|
+
dataset: str | None = None
|
|
132
|
+
media_type: str | None = None
|
|
133
|
+
overwrite: bool = False
|
|
134
|
+
keep_local: bool = Field(default=False, description="Keep the downloaded file in the workspace after it is stored.")
|
|
135
|
+
explain: bool = False
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def context_type(self) -> type[DateContext]:
|
|
139
|
+
return DateContext
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def result_type(self) -> type[HTTPArtifactCaptureResult]:
|
|
143
|
+
return HTTPArtifactCaptureResult
|
|
144
|
+
|
|
145
|
+
def key(self, context: DateContext) -> str:
|
|
146
|
+
value = context.date
|
|
147
|
+
return self.key_template.format(date=value.isoformat(), year=f"{value.year:04d}", month=f"{value.month:02d}", day=f"{value.day:02d}")
|
|
148
|
+
|
|
149
|
+
def _write(self, key: str, path: Path, media_type: str | None, dry_run: bool, metadata: dict[str, Any]) -> ArtifactWriteFileResult:
|
|
150
|
+
return self.writer(
|
|
151
|
+
ArtifactWriteFileContext(
|
|
152
|
+
key=key,
|
|
153
|
+
path=path,
|
|
154
|
+
media_type=media_type,
|
|
155
|
+
dataset=self.dataset,
|
|
156
|
+
stage="extract",
|
|
157
|
+
overwrite=self.overwrite,
|
|
158
|
+
dry_run=dry_run,
|
|
159
|
+
metadata=metadata,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _exists(self, key: str) -> bool:
|
|
164
|
+
exists = getattr(self.writer.store, "exists", None)
|
|
165
|
+
return bool(exists and exists(key))
|
|
166
|
+
|
|
167
|
+
@Flow.call
|
|
168
|
+
def __call__(self, context: DateContext) -> HTTPArtifactCaptureResult:
|
|
169
|
+
key = self.key(context)
|
|
170
|
+
manifest_key = f"{key}.manifest.json"
|
|
171
|
+
local_path = self.workspace / key
|
|
172
|
+
manifest_path = self.workspace / manifest_key
|
|
173
|
+
metadata = {"capture_date": context.date.isoformat()}
|
|
174
|
+
if self.explain:
|
|
175
|
+
return HTTPArtifactCaptureResult(
|
|
176
|
+
date=context.date,
|
|
177
|
+
key=key,
|
|
178
|
+
manifest_key=manifest_key,
|
|
179
|
+
status="planned",
|
|
180
|
+
write=self._write(key, local_path, self.media_type, True, metadata),
|
|
181
|
+
manifest_write=self._write(manifest_key, manifest_path, "application/json", True, metadata),
|
|
182
|
+
)
|
|
183
|
+
if not self.overwrite and self._exists(key):
|
|
184
|
+
return HTTPArtifactCaptureResult(date=context.date, key=key, manifest_key=manifest_key, status="exists")
|
|
185
|
+
|
|
186
|
+
download = self.download(HTTPDownloadContext(output_path=local_path))
|
|
187
|
+
manifest = {
|
|
188
|
+
"key": key,
|
|
189
|
+
"capture_date": context.date.isoformat(),
|
|
190
|
+
"captured_at": datetime.now(UTC).isoformat(),
|
|
191
|
+
**download.model_dump(mode="json", exclude={"type_", "path", "retry_events"}),
|
|
192
|
+
}
|
|
193
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
|
195
|
+
try:
|
|
196
|
+
write = self._write(key, local_path, self.media_type or download.content_type, False, {**metadata, "sha256": download.sha256})
|
|
197
|
+
manifest_write = self._write(manifest_key, manifest_path, "application/json", False, metadata)
|
|
198
|
+
finally:
|
|
199
|
+
if not self.keep_local:
|
|
200
|
+
local_path.unlink(missing_ok=True)
|
|
201
|
+
manifest_path.unlink(missing_ok=True)
|
|
202
|
+
status = next((value for value in (write.status, manifest_write.status) if value != "exists"), "exists")
|
|
203
|
+
return HTTPArtifactCaptureResult(
|
|
204
|
+
date=context.date, key=key, manifest_key=manifest_key, status=status, download=download, write=write, manifest_write=manifest_write
|
|
205
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import date
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
|
|
5
|
+
import httpx2
|
|
6
|
+
import pytest
|
|
7
|
+
from ccflow_etl import ArtifactWriteFileModel, LocalFileOutput
|
|
8
|
+
|
|
9
|
+
from ccflow_http import HTTPArtifactCaptureModel, HTTPAuth, HTTPConfig, HTTPDownloadContext, HTTPDownloadModel, HTTPRetryPolicy
|
|
10
|
+
|
|
11
|
+
PAYLOAD = b"x" * 5000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _download_model(handler, **kwargs):
|
|
15
|
+
return HTTPDownloadModel(
|
|
16
|
+
config=HTTPConfig(base_url="https://files.example.test", transport=httpx2.MockTransport(handler)),
|
|
17
|
+
path="/bulk/data.zip",
|
|
18
|
+
chunk_size=1024,
|
|
19
|
+
**kwargs,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_http_download_streams_body_to_file_with_checksum_and_headers(tmp_path):
|
|
24
|
+
def handler(request):
|
|
25
|
+
return httpx2.Response(
|
|
26
|
+
200, content=PAYLOAD, headers={"ETag": '"abc"', "Last-Modified": "Fri, 09 Oct 2026 04:35:00 GMT", "Content-Type": "application/zip"}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
target = tmp_path / "out" / "data.zip"
|
|
30
|
+
result = _download_model(handler, auth=HTTPAuth(strategy="api_key_query", name="key", value="secret"))(HTTPDownloadContext(output_path=target))
|
|
31
|
+
|
|
32
|
+
assert target.read_bytes() == PAYLOAD
|
|
33
|
+
assert result.size == len(PAYLOAD)
|
|
34
|
+
assert result.sha256 == sha256(PAYLOAD).hexdigest()
|
|
35
|
+
assert (result.etag, result.last_modified, result.content_type) == ('"abc"', "Fri, 09 Oct 2026 04:35:00 GMT", "application/zip")
|
|
36
|
+
assert result.url == "https://files.example.test/bulk/data.zip?key=***"
|
|
37
|
+
assert not list(target.parent.glob(".*.tmp"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_http_download_retries_status_and_leaves_no_partial_file_on_failure(tmp_path, monkeypatch):
|
|
41
|
+
monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
|
|
42
|
+
calls = []
|
|
43
|
+
|
|
44
|
+
def flaky(request):
|
|
45
|
+
calls.append(request)
|
|
46
|
+
return httpx2.Response(503) if len(calls) == 1 else httpx2.Response(200, content=PAYLOAD)
|
|
47
|
+
|
|
48
|
+
policy = HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0)
|
|
49
|
+
result = _download_model(flaky, retry_policy=policy)(HTTPDownloadContext(output_path=tmp_path / "data.zip"))
|
|
50
|
+
assert result.attempts == 2
|
|
51
|
+
|
|
52
|
+
target = tmp_path / "failed.zip"
|
|
53
|
+
with pytest.raises(RuntimeError, match="failed with status 404"):
|
|
54
|
+
_download_model(lambda request: httpx2.Response(404))(HTTPDownloadContext(output_path=target))
|
|
55
|
+
assert not target.exists()
|
|
56
|
+
assert not list(tmp_path.glob(".failed.zip.*.tmp"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _capture(tmp_path, handler, **kwargs):
|
|
60
|
+
store = LocalFileOutput(path=tmp_path / "store")
|
|
61
|
+
model = HTTPArtifactCaptureModel(
|
|
62
|
+
download=_download_model(handler),
|
|
63
|
+
writer=ArtifactWriteFileModel(store=store),
|
|
64
|
+
key_template="bulk/data/{year}/{month}/{date}.zip",
|
|
65
|
+
workspace=tmp_path / "workspace",
|
|
66
|
+
dataset="bulk-data",
|
|
67
|
+
**kwargs,
|
|
68
|
+
)
|
|
69
|
+
return model, store
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_http_capture_stores_payload_and_manifest_and_cleans_workspace(tmp_path):
|
|
73
|
+
model, store = _capture(tmp_path, lambda request: httpx2.Response(200, content=PAYLOAD, headers={"ETag": '"abc"'}))
|
|
74
|
+
|
|
75
|
+
result = model(date(2026, 10, 11))
|
|
76
|
+
manifest = json.loads(store.file_path("bulk/data/2026/10/2026-10-11.zip.manifest.json").read_text())
|
|
77
|
+
|
|
78
|
+
assert result.status == "written"
|
|
79
|
+
assert store.file_path("bulk/data/2026/10/2026-10-11.zip").read_bytes() == PAYLOAD
|
|
80
|
+
assert manifest["sha256"] == sha256(PAYLOAD).hexdigest()
|
|
81
|
+
assert manifest["size"] == len(PAYLOAD)
|
|
82
|
+
assert manifest["etag"] == '"abc"'
|
|
83
|
+
assert manifest["capture_date"] == "2026-10-11"
|
|
84
|
+
assert manifest["url"] == "https://files.example.test/bulk/data.zip"
|
|
85
|
+
assert not [path for path in (tmp_path / "workspace").rglob("*") if path.is_file()]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_http_capture_skips_existing_payload_without_downloading(tmp_path):
|
|
89
|
+
calls = []
|
|
90
|
+
model, _ = _capture(tmp_path, lambda request: calls.append(request) or httpx2.Response(200, content=PAYLOAD))
|
|
91
|
+
model(date(2026, 10, 11))
|
|
92
|
+
|
|
93
|
+
result = model(date(2026, 10, 11))
|
|
94
|
+
|
|
95
|
+
assert result.status == "exists"
|
|
96
|
+
assert len(calls) == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_http_capture_explain_does_no_network_or_storage_io(tmp_path):
|
|
100
|
+
def handler(request):
|
|
101
|
+
raise AssertionError("explain made a request")
|
|
102
|
+
|
|
103
|
+
model, _ = _capture(tmp_path, handler, explain=True)
|
|
104
|
+
|
|
105
|
+
result = model(date(2026, 10, 11))
|
|
106
|
+
|
|
107
|
+
assert result.status == "planned"
|
|
108
|
+
assert result.write.status == "planned"
|
|
109
|
+
assert not (tmp_path / "store").exists()
|
|
110
|
+
assert not (tmp_path / "workspace").exists()
|
|
@@ -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,123 @@ 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_raises_on_accepted_error_status_after_first_page():
|
|
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
|
+
with pytest.raises(RuntimeError, match="status 404 on page 2"):
|
|
507
|
+
model(HTTPRequestContext())
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def test_http_model_redacts_custom_query_auth_name_everywhere(caplog):
|
|
511
|
+
seen_urls = []
|
|
512
|
+
|
|
513
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
514
|
+
seen_urls.append(str(request.url))
|
|
515
|
+
if len(seen_urls) == 1:
|
|
516
|
+
return httpx2.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v1/tickers?cursor=2"})
|
|
517
|
+
return httpx2.Response(200, json={"results": [{"ticker": "BBB"}]})
|
|
518
|
+
|
|
519
|
+
model = HTTPModel(
|
|
520
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
521
|
+
path="/v1/tickers",
|
|
522
|
+
paginate=True,
|
|
523
|
+
auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
with caplog.at_level("INFO", logger="httpx2"):
|
|
527
|
+
result = model(HTTPRequestContext())
|
|
528
|
+
|
|
529
|
+
assert seen_urls == ["https://api.example.test/v1/tickers?key=custom-secret", "https://api.example.test/v1/tickers?cursor=2&key=custom-secret"]
|
|
530
|
+
assert "custom-secret" not in caplog.text
|
|
531
|
+
assert caplog.text.count("key=***") == 2
|
|
532
|
+
assert result.url == "https://api.example.test/v1/tickers?cursor=2&key=***"
|
|
533
|
+
assert safe_request_dump(model.build_request(HTTPRequestContext()))["params"] == {"key": "***"}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_http_model_does_not_leak_query_secrets_through_transport_errors(monkeypatch):
|
|
537
|
+
monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
|
|
538
|
+
calls = []
|
|
539
|
+
|
|
540
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
541
|
+
calls.append(request)
|
|
542
|
+
if len(calls) == 1:
|
|
543
|
+
raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
|
|
544
|
+
raise httpx2.ReadTimeout(f"read timed out for {request.url}", request=request)
|
|
545
|
+
|
|
546
|
+
model = HTTPModel(
|
|
547
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
548
|
+
path="/v1/tickers",
|
|
549
|
+
auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
|
|
550
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
with pytest.raises(RuntimeError, match="failed with ReadTimeout") as error:
|
|
554
|
+
model(HTTPRequestContext())
|
|
555
|
+
|
|
556
|
+
assert error.value.__cause__ is None
|
|
557
|
+
assert error.value.__suppress_context__
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_http_model_redacts_query_secrets_in_retry_event_messages(monkeypatch):
|
|
561
|
+
monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
|
|
562
|
+
calls = []
|
|
563
|
+
|
|
564
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
565
|
+
calls.append(request)
|
|
566
|
+
if len(calls) == 1:
|
|
567
|
+
raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
|
|
568
|
+
return httpx2.Response(200, json={"status": "OK"})
|
|
569
|
+
|
|
570
|
+
model = HTTPModel(
|
|
571
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
572
|
+
path="/v1/tickers",
|
|
573
|
+
auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
|
|
574
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
result = model(HTTPRequestContext())
|
|
578
|
+
|
|
579
|
+
assert result.retry_events[0]["message"] == "connection reset for https://api.example.test/v1/tickers?key=***"
|
|
580
|
+
|
|
581
|
+
|
|
442
582
|
def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
|
|
443
|
-
def csv_handler(request:
|
|
444
|
-
return
|
|
583
|
+
def csv_handler(request: httpx2.Request) -> httpx2.Response:
|
|
584
|
+
return httpx2.Response(200, text="ticker,volume\nAAA,10\nBBB,20\n")
|
|
445
585
|
|
|
446
586
|
csv_result = HTTPModel(
|
|
447
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
587
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(csv_handler)),
|
|
448
588
|
path="/csv",
|
|
449
589
|
response_format="csv",
|
|
450
590
|
)(HTTPRequestContext())
|
|
451
591
|
|
|
452
|
-
def gzip_handler(request:
|
|
453
|
-
return
|
|
592
|
+
def gzip_handler(request: httpx2.Request) -> httpx2.Response:
|
|
593
|
+
return httpx2.Response(200, content=compress(b'{"status":"OK"}'))
|
|
454
594
|
|
|
455
595
|
gzip_result = HTTPModel(
|
|
456
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
596
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(gzip_handler)),
|
|
457
597
|
path="/gzip",
|
|
458
598
|
response_format="gzip",
|
|
459
599
|
)(HTTPRequestContext())
|
|
@@ -465,14 +605,14 @@ def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
|
|
|
465
605
|
def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transport():
|
|
466
606
|
cursor_requests = []
|
|
467
607
|
|
|
468
|
-
def cursor_handler(request:
|
|
608
|
+
def cursor_handler(request: httpx2.Request) -> httpx2.Response:
|
|
469
609
|
cursor_requests.append(dict(request.url.params))
|
|
470
610
|
if "cursor" not in request.url.params:
|
|
471
|
-
return
|
|
472
|
-
return
|
|
611
|
+
return httpx2.Response(200, json={"results": [{"id": 1}], "next_cursor": "abc"})
|
|
612
|
+
return httpx2.Response(200, json={"results": [{"id": 2}]})
|
|
473
613
|
|
|
474
614
|
cursor_result = HTTPModel(
|
|
475
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
615
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(cursor_handler)),
|
|
476
616
|
path="/items",
|
|
477
617
|
paginate=True,
|
|
478
618
|
pagination_mode="cursor",
|
|
@@ -482,14 +622,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
|
|
|
482
622
|
|
|
483
623
|
page_requests = []
|
|
484
624
|
|
|
485
|
-
def page_handler(request:
|
|
625
|
+
def page_handler(request: httpx2.Request) -> httpx2.Response:
|
|
486
626
|
page_requests.append(dict(request.url.params))
|
|
487
627
|
page = int(request.url.params["page"])
|
|
488
628
|
payload = {1: [{"id": 1}], 2: [{"id": 2}]}.get(page, [])
|
|
489
|
-
return
|
|
629
|
+
return httpx2.Response(200, json={"results": payload})
|
|
490
630
|
|
|
491
631
|
page_result = HTTPModel(
|
|
492
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
632
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(page_handler)),
|
|
493
633
|
path="/items",
|
|
494
634
|
paginate=True,
|
|
495
635
|
pagination_mode="page",
|
|
@@ -500,14 +640,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
|
|
|
500
640
|
|
|
501
641
|
offset_requests = []
|
|
502
642
|
|
|
503
|
-
def offset_handler(request:
|
|
643
|
+
def offset_handler(request: httpx2.Request) -> httpx2.Response:
|
|
504
644
|
offset_requests.append(dict(request.url.params))
|
|
505
645
|
offset = int(request.url.params["offset"])
|
|
506
646
|
payload = {0: [{"id": 1}], 2: [{"id": 2}]}.get(offset, [])
|
|
507
|
-
return
|
|
647
|
+
return httpx2.Response(200, json={"results": payload})
|
|
508
648
|
|
|
509
649
|
offset_result = HTTPModel(
|
|
510
|
-
config=HTTPConfig(base_url="https://api.example.test", transport=
|
|
650
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(offset_handler)),
|
|
511
651
|
path="/items",
|
|
512
652
|
paginate=True,
|
|
513
653
|
pagination_mode="offset",
|
|
@@ -546,29 +686,30 @@ def _retrying_client(calls, response_factory):
|
|
|
546
686
|
|
|
547
687
|
|
|
548
688
|
def _raise_status(status_code, headers=None):
|
|
549
|
-
request =
|
|
550
|
-
response =
|
|
551
|
-
raise
|
|
689
|
+
request = httpx2.Request("GET", "https://api.example.test/v1/tickers")
|
|
690
|
+
response = httpx2.Response(status_code, request=request, headers=headers or {})
|
|
691
|
+
raise httpx2.HTTPStatusError("error", request=request, response=response)
|
|
552
692
|
|
|
553
693
|
|
|
554
|
-
|
|
694
|
+
@pytest.mark.parametrize(("exception_type", "category"), [(httpx2.ConnectError, "connection"), (httpx2.RemoteProtocolError, "exception")])
|
|
695
|
+
def test_http_model_retries_transport_error(monkeypatch, exception_type, category):
|
|
555
696
|
calls = []
|
|
556
697
|
|
|
557
698
|
def fail_once():
|
|
558
|
-
raise
|
|
699
|
+
raise exception_type("transport failed")
|
|
559
700
|
|
|
560
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
701
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, fail_once))
|
|
561
702
|
|
|
562
703
|
result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
|
|
563
704
|
|
|
564
705
|
assert len(calls) == 2
|
|
565
706
|
assert result.attempts == 2
|
|
566
|
-
assert result.retry_events[0]["category"] ==
|
|
707
|
+
assert result.retry_events[0]["category"] == category
|
|
567
708
|
|
|
568
709
|
|
|
569
710
|
def test_http_model_honors_retry_after_seconds_over_backoff(monkeypatch):
|
|
570
711
|
calls = []
|
|
571
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
712
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "45"})))
|
|
572
713
|
|
|
573
714
|
model = HTTPModel(
|
|
574
715
|
base_url="https://api.example.test",
|
|
@@ -587,7 +728,7 @@ def test_http_model_honors_retry_after_seconds_over_backoff(monkeypatch):
|
|
|
587
728
|
|
|
588
729
|
def test_http_model_uses_backoff_when_it_exceeds_retry_after(monkeypatch):
|
|
589
730
|
calls = []
|
|
590
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
731
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "1"})))
|
|
591
732
|
|
|
592
733
|
model = HTTPModel(
|
|
593
734
|
base_url="https://api.example.test",
|
|
@@ -604,7 +745,7 @@ def test_http_model_uses_backoff_when_it_exceeds_retry_after(monkeypatch):
|
|
|
604
745
|
|
|
605
746
|
def test_http_model_caps_retry_after_at_retry_after_max(monkeypatch):
|
|
606
747
|
calls = []
|
|
607
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
748
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "9999"})))
|
|
608
749
|
|
|
609
750
|
model = HTTPModel(
|
|
610
751
|
base_url="https://api.example.test",
|
|
@@ -625,7 +766,7 @@ def test_http_model_honors_retry_after_http_date(monkeypatch):
|
|
|
625
766
|
|
|
626
767
|
calls = []
|
|
627
768
|
retry_at = format_datetime(datetime.now(UTC) + timedelta(seconds=60))
|
|
628
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
769
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": retry_at})))
|
|
629
770
|
|
|
630
771
|
model = HTTPModel(
|
|
631
772
|
base_url="https://api.example.test",
|
|
@@ -643,7 +784,7 @@ def test_http_model_honors_retry_after_http_date(monkeypatch):
|
|
|
643
784
|
|
|
644
785
|
def test_http_model_ignores_invalid_retry_after(monkeypatch):
|
|
645
786
|
calls = []
|
|
646
|
-
monkeypatch.setattr("ccflow_http.base.
|
|
787
|
+
monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "soon"})))
|
|
647
788
|
|
|
648
789
|
model = HTTPModel(
|
|
649
790
|
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.3.0"
|
|
16
16
|
requires-python = ">=3.11"
|
|
17
17
|
keywords = []
|
|
18
18
|
|
|
@@ -30,8 +30,8 @@ classifiers = [
|
|
|
30
30
|
|
|
31
31
|
dependencies = [
|
|
32
32
|
"ccflow>=0.8.5",
|
|
33
|
-
"ccflow-etl>=0.
|
|
34
|
-
"
|
|
33
|
+
"ccflow-etl>=0.4.0",
|
|
34
|
+
"httpx2",
|
|
35
35
|
"jinja2",
|
|
36
36
|
]
|
|
37
37
|
|
|
@@ -43,6 +43,7 @@ develop = [
|
|
|
43
43
|
"codespell",
|
|
44
44
|
"hatchling",
|
|
45
45
|
"mdformat",
|
|
46
|
+
"mdformat-frontmatter",
|
|
46
47
|
"mdformat-tables>=1",
|
|
47
48
|
"pytest",
|
|
48
49
|
"pytest-cov",
|
|
@@ -60,7 +61,7 @@ Repository = "https://github.com/1kbgz/ccflow-http"
|
|
|
60
61
|
Homepage = "https://github.com/1kbgz/ccflow-http"
|
|
61
62
|
|
|
62
63
|
[tool.bumpversion]
|
|
63
|
-
current_version = "0.
|
|
64
|
+
current_version = "0.3.0"
|
|
64
65
|
commit = true
|
|
65
66
|
tag = true
|
|
66
67
|
commit_args = "-s"
|
|
@@ -78,7 +79,7 @@ replace = 'version = "{new_version}"'
|
|
|
78
79
|
[tool.coverage.run]
|
|
79
80
|
branch = true
|
|
80
81
|
omit = [
|
|
81
|
-
"ccflow_http/tests/integration
|
|
82
|
+
"ccflow_http/tests/integration/*",
|
|
82
83
|
]
|
|
83
84
|
|
|
84
85
|
[tool.coverage.report]
|
|
File without changes
|
|
File without changes
|