ccflow-http 0.2.0__tar.gz → 0.4.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.2.0 → ccflow_http-0.4.0}/PKG-INFO +4 -3
- {ccflow_http-0.2.0 → ccflow_http-0.4.0}/README.md +1 -1
- ccflow_http-0.4.0/ccflow_http/__init__.py +4 -0
- {ccflow_http-0.2.0 → ccflow_http-0.4.0}/ccflow_http/base.py +149 -67
- ccflow_http-0.4.0/ccflow_http/download.py +205 -0
- ccflow_http-0.4.0/ccflow_http/tests/test_download.py +110 -0
- {ccflow_http-0.2.0 → ccflow_http-0.4.0}/ccflow_http/tests/test_model.py +140 -6
- {ccflow_http-0.2.0 → ccflow_http-0.4.0}/pyproject.toml +5 -4
- ccflow_http-0.2.0/ccflow_http/__init__.py +0 -3
- {ccflow_http-0.2.0 → ccflow_http-0.4.0}/.gitignore +0 -0
- {ccflow_http-0.2.0 → ccflow_http-0.4.0}/LICENSE +0 -0
- {ccflow_http-0.2.0 → ccflow_http-0.4.0}/ccflow_http/tests/test_all.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ccflow-http
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,7 +17,7 @@ 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
22
|
Requires-Dist: httpx2
|
|
23
23
|
Requires-Dist: jinja2
|
|
@@ -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, 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
|
|
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, a configurable `RedactionPolicy` (`HTTPModel.redaction`: exact names, substrings, and patterns, with `auth` header and query names added automatically) that masks secret header and query values in `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
|
|
|
@@ -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, 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
|
|
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, a configurable `RedactionPolicy` (`HTTPModel.redaction`: exact names, substrings, and patterns, with `auth` header and query names added automatically) that masks secret header and query values in `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
|
|
|
@@ -1,4 +1,7 @@
|
|
|
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
|
|
@@ -14,7 +17,7 @@ from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, P
|
|
|
14
17
|
from ccflow.utils.retry import RetryPolicy
|
|
15
18
|
from ccflow_etl import ExecutionPolicy
|
|
16
19
|
from jinja2 import Environment
|
|
17
|
-
from pydantic import Field
|
|
20
|
+
from pydantic import Field, PrivateAttr, field_validator
|
|
18
21
|
|
|
19
22
|
__all__ = (
|
|
20
23
|
"HTTPAuth",
|
|
@@ -26,6 +29,7 @@ __all__ = (
|
|
|
26
29
|
"HTTPResponseResult",
|
|
27
30
|
"HTTPResult",
|
|
28
31
|
"HTTPRetryPolicy",
|
|
32
|
+
"RedactionPolicy",
|
|
29
33
|
"redact_mapping",
|
|
30
34
|
"redact_url",
|
|
31
35
|
"safe_request_dump",
|
|
@@ -75,26 +79,79 @@ class HTTPRequest(BaseModel):
|
|
|
75
79
|
headers: dict[str, str] = Field(default_factory=dict)
|
|
76
80
|
json_data: Any | None = None
|
|
77
81
|
content: bytes | str | None = None
|
|
82
|
+
# Redaction for this request: the model's policy plus any names its auth places credentials under.
|
|
83
|
+
_redaction: "RedactionPolicy" = PrivateAttr(default_factory=lambda: _DEFAULT_REDACTION)
|
|
78
84
|
|
|
79
85
|
|
|
80
|
-
|
|
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
|
|
86
|
+
_QUERY_PAIR = re.compile(r"([?&])([^=&\s'\"]+)=([^&\s'\"]*)")
|
|
83
87
|
|
|
84
88
|
|
|
85
|
-
def
|
|
86
|
-
return
|
|
89
|
+
def _normalize_name(name: str) -> str:
|
|
90
|
+
return name.lower().replace("_", "").replace("-", "")
|
|
87
91
|
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
class RedactionPolicy(BaseModel):
|
|
94
|
+
"""Which header and query parameter names carry secrets, and how their values are masked.
|
|
95
|
+
|
|
96
|
+
Names are compared after lowercasing and removing hyphens and underscores, so ``X-Api-Key`` and ``x_api_key``
|
|
97
|
+
are the same name. A name is sensitive when it is one of ``names``, contains one of ``substrings``, or matches one
|
|
98
|
+
of ``patterns`` (regular expressions searched in the normalized name). Set it on ``HTTPModel.redaction`` to cover
|
|
99
|
+
a provider's own header or parameter names; names used by ``auth`` are added automatically.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
names: set[str] = Field(default_factory=lambda: {"authorization", "proxyauthorization", "cookie", "setcookie", "password"})
|
|
103
|
+
substrings: set[str] = Field(default_factory=lambda: {"apikey", "token", "secret", "password"})
|
|
104
|
+
patterns: list[str] = Field(default_factory=list)
|
|
105
|
+
mask: str = "***"
|
|
106
|
+
|
|
107
|
+
@field_validator("names", "substrings", mode="after")
|
|
108
|
+
@classmethod
|
|
109
|
+
def _normalize(cls, values: set[str]) -> set[str]:
|
|
110
|
+
return {_normalize_name(value) for value in values}
|
|
111
|
+
|
|
112
|
+
def with_names(self, *names: str) -> "RedactionPolicy":
|
|
113
|
+
"""Return a copy that also treats ``names`` as sensitive."""
|
|
114
|
+
if not names:
|
|
115
|
+
return self
|
|
116
|
+
return self.model_copy(update={"names": self.names | {_normalize_name(name) for name in names}})
|
|
117
|
+
|
|
118
|
+
def is_sensitive(self, name: str) -> bool:
|
|
119
|
+
normalized = _normalize_name(name)
|
|
120
|
+
return (
|
|
121
|
+
normalized in self.names
|
|
122
|
+
or any(substring in normalized for substring in self.substrings)
|
|
123
|
+
or any(re.search(pattern, normalized) for pattern in self.patterns)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def redact_mapping(self, values: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
return {key: self.mask if self.is_sensitive(key) else value for key, value in values.items()}
|
|
128
|
+
|
|
129
|
+
def redact_url(self, url: str) -> str:
|
|
130
|
+
parts = urlsplit(url)
|
|
131
|
+
if not parts.query:
|
|
132
|
+
return url
|
|
133
|
+
pairs = []
|
|
134
|
+
for pair in parts.query.split("&"):
|
|
135
|
+
key = pair.split("=", 1)[0]
|
|
136
|
+
pairs.append(f"{key}={self.mask}" if self.is_sensitive(unquote_plus(key)) else pair)
|
|
137
|
+
return urlunsplit(parts._replace(query="&".join(pairs)))
|
|
138
|
+
|
|
139
|
+
def redact_text(self, text: str) -> str:
|
|
140
|
+
"""Redact secret query values that appear anywhere in free text, such as an exception message quoting a URL."""
|
|
141
|
+
return _QUERY_PAIR.sub(lambda match: f"{match[1]}{match[2]}={self.mask}" if self.is_sensitive(unquote_plus(match[2])) else match[0], text)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_DEFAULT_REDACTION = RedactionPolicy()
|
|
145
|
+
# Redaction policy of the request in flight, so the httpx2 log filter can apply it.
|
|
146
|
+
_REQUEST_REDACTION: ContextVar[RedactionPolicy | None] = ContextVar("ccflow_http_request_redaction", default=None)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def redact_mapping(values: dict[str, Any], extra: Collection[str] = (), policy: RedactionPolicy | None = None) -> dict[str, Any]:
|
|
150
|
+
return (policy or _DEFAULT_REDACTION).with_names(*extra).redact_mapping(values)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def redact_url(url: str, extra: Collection[str] = (), policy: RedactionPolicy | None = None) -> str:
|
|
154
|
+
return (policy or _DEFAULT_REDACTION).with_names(*extra).redact_url(url)
|
|
98
155
|
|
|
99
156
|
|
|
100
157
|
class _RedactURLFilter(Filter):
|
|
@@ -102,7 +159,8 @@ class _RedactURLFilter(Filter):
|
|
|
102
159
|
|
|
103
160
|
def filter(self, record: LogRecord) -> bool:
|
|
104
161
|
if isinstance(record.args, tuple):
|
|
105
|
-
|
|
162
|
+
policy = _REQUEST_REDACTION.get() or _DEFAULT_REDACTION
|
|
163
|
+
record.args = tuple(policy.redact_url(str(arg)) if isinstance(arg, httpx2.URL) else arg for arg in record.args)
|
|
106
164
|
return True
|
|
107
165
|
|
|
108
166
|
|
|
@@ -111,8 +169,8 @@ getLogger("httpx2").addFilter(_RedactURLFilter())
|
|
|
111
169
|
|
|
112
170
|
def safe_request_dump(request: HTTPRequest) -> dict[str, Any]:
|
|
113
171
|
request_data = request.model_dump(exclude={"type_"})
|
|
114
|
-
request_data["params"] = redact_mapping(request_data.get("params", {}))
|
|
115
|
-
request_data["headers"] = redact_mapping(request_data.get("headers", {}))
|
|
172
|
+
request_data["params"] = request._redaction.redact_mapping(request_data.get("params", {}))
|
|
173
|
+
request_data["headers"] = request._redaction.redact_mapping(request_data.get("headers", {}))
|
|
116
174
|
return request_data
|
|
117
175
|
|
|
118
176
|
|
|
@@ -225,6 +283,9 @@ class HTTPModel(CallableModel):
|
|
|
225
283
|
description="Error status codes returned as results with value None instead of raising, such as 404 for lookups that may miss.",
|
|
226
284
|
)
|
|
227
285
|
execution_policy: ExecutionPolicy | None = None
|
|
286
|
+
redaction: RedactionPolicy = Field(
|
|
287
|
+
default_factory=RedactionPolicy, description="Header and query parameter names whose values are masked in logs, results, and errors."
|
|
288
|
+
)
|
|
228
289
|
paginate: bool = False
|
|
229
290
|
max_pages: int = 100
|
|
230
291
|
pagination_mode: HTTPPaginationMode = "next_url"
|
|
@@ -325,7 +386,7 @@ class HTTPModel(CallableModel):
|
|
|
325
386
|
rendered_headers = self._render_mapping(headers, data)
|
|
326
387
|
self._apply_auth(rendered_headers, rendered_query, data)
|
|
327
388
|
|
|
328
|
-
|
|
389
|
+
request = HTTPRequest(
|
|
329
390
|
method=self.method,
|
|
330
391
|
url=self._render(path, data),
|
|
331
392
|
params=rendered_query,
|
|
@@ -333,6 +394,11 @@ class HTTPModel(CallableModel):
|
|
|
333
394
|
json_data=context.json_body if context.json_body is not None else self.json_body,
|
|
334
395
|
content=context.content if context.content is not None else self.content,
|
|
335
396
|
)
|
|
397
|
+
auth_names = []
|
|
398
|
+
if self.auth.strategy in ("api_key_query", "api_key_header"):
|
|
399
|
+
auth_names.append(self._render_required_auth_value(self.auth.name, "name", data))
|
|
400
|
+
request._redaction = self.redaction.with_names(*auth_names)
|
|
401
|
+
return request
|
|
336
402
|
|
|
337
403
|
def _response_value(self, response: httpx2.Response) -> Any:
|
|
338
404
|
match self.response_format:
|
|
@@ -398,6 +464,16 @@ class HTTPModel(CallableModel):
|
|
|
398
464
|
current = current.get(part)
|
|
399
465
|
return current
|
|
400
466
|
|
|
467
|
+
def _send(self, client: httpx2.Client, request: HTTPRequest) -> httpx2.Response:
|
|
468
|
+
return client.request(
|
|
469
|
+
method=request.method,
|
|
470
|
+
url=request.url,
|
|
471
|
+
params=request.params,
|
|
472
|
+
headers=request.headers,
|
|
473
|
+
json=request.json_data,
|
|
474
|
+
content=request.content,
|
|
475
|
+
)
|
|
476
|
+
|
|
401
477
|
def _request_once(
|
|
402
478
|
self, client: httpx2.Client, request: HTTPRequest, previous_started_at: float | None
|
|
403
479
|
) -> tuple[httpx2.Response, int, list[dict[str, Any]], float]:
|
|
@@ -409,19 +485,14 @@ class HTTPModel(CallableModel):
|
|
|
409
485
|
attempts += 1
|
|
410
486
|
previous_started_at = self._throttle(previous_started_at)
|
|
411
487
|
try:
|
|
412
|
-
response = client
|
|
413
|
-
method=request.method,
|
|
414
|
-
url=request.url,
|
|
415
|
-
params=request.params,
|
|
416
|
-
headers=request.headers,
|
|
417
|
-
json=request.json_data,
|
|
418
|
-
content=request.content,
|
|
419
|
-
)
|
|
488
|
+
response = self._send(client, request)
|
|
420
489
|
if response.status_code in self.accepted_status_codes:
|
|
421
490
|
return response, attempts, events, previous_started_at
|
|
422
491
|
response.raise_for_status()
|
|
423
492
|
return response, attempts, events, previous_started_at
|
|
424
493
|
except httpx2.HTTPStatusError as exc:
|
|
494
|
+
if exc.response is not None:
|
|
495
|
+
exc.response.close()
|
|
425
496
|
status_code = exc.response.status_code if exc.response is not None else None
|
|
426
497
|
if retry_policy.should_retry_status(status_code, attempts):
|
|
427
498
|
delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
|
|
@@ -465,7 +536,7 @@ class HTTPModel(CallableModel):
|
|
|
465
536
|
delay_seconds=delay_seconds,
|
|
466
537
|
exception_type=type(exc).__name__,
|
|
467
538
|
category=retry_policy.exception_category(exc),
|
|
468
|
-
message=str(exc),
|
|
539
|
+
message=request._redaction.redact_text(str(exc)),
|
|
469
540
|
)
|
|
470
541
|
)
|
|
471
542
|
self._sleep(delay_seconds)
|
|
@@ -477,12 +548,13 @@ class HTTPModel(CallableModel):
|
|
|
477
548
|
outcome="failed",
|
|
478
549
|
exception_type=type(exc).__name__,
|
|
479
550
|
category=retry_policy.exception_category(exc),
|
|
480
|
-
message=str(exc),
|
|
551
|
+
message=request._redaction.redact_text(str(exc)),
|
|
481
552
|
)
|
|
482
553
|
)
|
|
483
|
-
|
|
554
|
+
# httpx2 exceptions keep the full request, including secret query values, so do not chain them.
|
|
555
|
+
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
|
|
484
556
|
except httpx2.HTTPError as exc:
|
|
485
|
-
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from
|
|
557
|
+
raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
|
|
486
558
|
|
|
487
559
|
def _merge_page_values(self, values: list[Any]) -> Any:
|
|
488
560
|
if not values:
|
|
@@ -506,7 +578,7 @@ class HTTPModel(CallableModel):
|
|
|
506
578
|
next_url_parts = urlsplit(next_url)
|
|
507
579
|
next_url_params = dict(parse_qsl(next_url_parts.query, keep_blank_values=True))
|
|
508
580
|
for key, value in request.params.items():
|
|
509
|
-
if key not in next_url_params and
|
|
581
|
+
if key not in next_url_params and request._redaction.is_sensitive(key):
|
|
510
582
|
next_url_params[key] = value
|
|
511
583
|
next_url_without_query = urlunsplit((next_url_parts.scheme, next_url_parts.netloc, next_url_parts.path, "", next_url_parts.fragment))
|
|
512
584
|
return request.model_copy(update={"url": next_url_without_query, "params": next_url_params})
|
|
@@ -557,39 +629,49 @@ class HTTPModel(CallableModel):
|
|
|
557
629
|
@Flow.call
|
|
558
630
|
def __call__(self, context: HTTPRequestContext) -> HTTPResult:
|
|
559
631
|
request = self._initial_paginated_request(self.build_request(context))
|
|
632
|
+
redaction = request._redaction
|
|
560
633
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
)
|
|
634
|
+
token = _REQUEST_REDACTION.set(redaction)
|
|
635
|
+
try:
|
|
636
|
+
with httpx2.Client(**self._client_kwargs()) as client:
|
|
637
|
+
values = []
|
|
638
|
+
retry_events = []
|
|
639
|
+
total_attempts = 0
|
|
640
|
+
pages = 0
|
|
641
|
+
previous_started_at = None
|
|
642
|
+
while True:
|
|
643
|
+
response, attempts, events, previous_started_at = self._request_once(client, request, previous_started_at)
|
|
644
|
+
total_attempts += attempts
|
|
645
|
+
retry_events.extend(events)
|
|
646
|
+
pages += 1
|
|
647
|
+
accepted_error = response.status_code in self.accepted_status_codes
|
|
648
|
+
if accepted_error:
|
|
649
|
+
if pages > 1:
|
|
650
|
+
raise RuntimeError(
|
|
651
|
+
f"HTTP {request.method} {self._safe_url(request)} returned status {response.status_code} on page {pages}; "
|
|
652
|
+
"accepted statuses are only returned for the first page"
|
|
653
|
+
)
|
|
654
|
+
break
|
|
655
|
+
value = self._response_value(response)
|
|
656
|
+
values.append(value)
|
|
657
|
+
|
|
658
|
+
if not self.paginate or pages >= self.max_pages:
|
|
659
|
+
break
|
|
660
|
+
next_request = self._next_paginated_request(request, value)
|
|
661
|
+
if next_request is None:
|
|
662
|
+
break
|
|
663
|
+
request = next_request
|
|
664
|
+
finally:
|
|
665
|
+
_REQUEST_REDACTION.reset(token)
|
|
666
|
+
|
|
667
|
+
return HTTPResult(
|
|
668
|
+
value=None if accepted_error else (self._merge_page_values(values) if self.paginate else values[-1]),
|
|
669
|
+
status_code=response.status_code,
|
|
670
|
+
headers=dict(response.headers or {}),
|
|
671
|
+
url=redaction.redact_url(str(response.url)),
|
|
672
|
+
attempts=total_attempts,
|
|
673
|
+
pages=pages,
|
|
674
|
+
rate_limit=self._rate_limit_headers(dict(response.headers or {})),
|
|
675
|
+
retry_events=retry_events,
|
|
676
|
+
retry_summary=self._retry_summary(retry_events, attempts=total_attempts, succeeded=True),
|
|
677
|
+
)
|
|
@@ -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_REDACTION, HTTPModel, HTTPRequest, HTTPRequestContext
|
|
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
|
+
redaction = request._redaction
|
|
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_REDACTION.set(redaction)
|
|
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_REDACTION.reset(token)
|
|
96
|
+
return HTTPDownloadResult(
|
|
97
|
+
path=str(path),
|
|
98
|
+
size=size,
|
|
99
|
+
sha256=digest.hexdigest(),
|
|
100
|
+
status_code=response.status_code,
|
|
101
|
+
url=redaction.redact_url(str(response.url)),
|
|
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()
|
|
@@ -18,6 +18,7 @@ from ccflow_http import (
|
|
|
18
18
|
HTTPRequestContext,
|
|
19
19
|
HTTPResponseResult,
|
|
20
20
|
HTTPRetryPolicy,
|
|
21
|
+
RedactionPolicy,
|
|
21
22
|
redact_url,
|
|
22
23
|
safe_request_dump,
|
|
23
24
|
)
|
|
@@ -118,14 +119,14 @@ def test_safe_request_dump_redacts_secret_params_and_headers():
|
|
|
118
119
|
method="GET",
|
|
119
120
|
url="/v1/tickers/AAA",
|
|
120
121
|
params={"date": "2024-01-03", "apiKey": "query-secret", "page_token": "cursor-secret"},
|
|
121
|
-
headers={"Authorization": "Bearer header-secret", "X-Request-ID": "abc"},
|
|
122
|
+
headers={"Authorization": "Bearer header-secret", "X-OPENFIGI-APIKEY": "vendor-secret", "X-Request-ID": "abc"},
|
|
122
123
|
)
|
|
123
124
|
|
|
124
125
|
assert safe_request_dump(request) == {
|
|
125
126
|
"method": "GET",
|
|
126
127
|
"url": "/v1/tickers/AAA",
|
|
127
128
|
"params": {"date": "2024-01-03", "apiKey": "***", "page_token": "***"},
|
|
128
|
-
"headers": {"Authorization": "***", "X-Request-ID": "abc"},
|
|
129
|
+
"headers": {"Authorization": "***", "X-OPENFIGI-APIKEY": "***", "X-Request-ID": "abc"},
|
|
129
130
|
"json_data": None,
|
|
130
131
|
"content": None,
|
|
131
132
|
}
|
|
@@ -490,7 +491,7 @@ def test_http_model_returns_accepted_error_status_without_raising():
|
|
|
490
491
|
HTTPModel(config=config, path="/v1/tickers/ZZZZ")(HTTPRequestContext())
|
|
491
492
|
|
|
492
493
|
|
|
493
|
-
def
|
|
494
|
+
def test_http_model_raises_on_accepted_error_status_after_first_page():
|
|
494
495
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
495
496
|
if "cursor" in request.url.params:
|
|
496
497
|
return httpx2.Response(404)
|
|
@@ -503,11 +504,80 @@ def test_http_model_stops_pagination_on_accepted_error_status():
|
|
|
503
504
|
accepted_status_codes=[404],
|
|
504
505
|
)
|
|
505
506
|
|
|
507
|
+
with pytest.raises(RuntimeError, match="status 404 on page 2"):
|
|
508
|
+
model(HTTPRequestContext())
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_http_model_redacts_custom_query_auth_name_everywhere(caplog):
|
|
512
|
+
seen_urls = []
|
|
513
|
+
|
|
514
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
515
|
+
seen_urls.append(str(request.url))
|
|
516
|
+
if len(seen_urls) == 1:
|
|
517
|
+
return httpx2.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v1/tickers?cursor=2"})
|
|
518
|
+
return httpx2.Response(200, json={"results": [{"ticker": "BBB"}]})
|
|
519
|
+
|
|
520
|
+
model = HTTPModel(
|
|
521
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
522
|
+
path="/v1/tickers",
|
|
523
|
+
paginate=True,
|
|
524
|
+
auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
with caplog.at_level("INFO", logger="httpx2"):
|
|
528
|
+
result = model(HTTPRequestContext())
|
|
529
|
+
|
|
530
|
+
assert seen_urls == ["https://api.example.test/v1/tickers?key=custom-secret", "https://api.example.test/v1/tickers?cursor=2&key=custom-secret"]
|
|
531
|
+
assert "custom-secret" not in caplog.text
|
|
532
|
+
assert caplog.text.count("key=***") == 2
|
|
533
|
+
assert result.url == "https://api.example.test/v1/tickers?cursor=2&key=***"
|
|
534
|
+
assert safe_request_dump(model.build_request(HTTPRequestContext()))["params"] == {"key": "***"}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_http_model_does_not_leak_query_secrets_through_transport_errors(monkeypatch):
|
|
538
|
+
monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
|
|
539
|
+
calls = []
|
|
540
|
+
|
|
541
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
542
|
+
calls.append(request)
|
|
543
|
+
if len(calls) == 1:
|
|
544
|
+
raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
|
|
545
|
+
raise httpx2.ReadTimeout(f"read timed out for {request.url}", request=request)
|
|
546
|
+
|
|
547
|
+
model = HTTPModel(
|
|
548
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
549
|
+
path="/v1/tickers",
|
|
550
|
+
auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
|
|
551
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
with pytest.raises(RuntimeError, match="failed with ReadTimeout") as error:
|
|
555
|
+
model(HTTPRequestContext())
|
|
556
|
+
|
|
557
|
+
assert error.value.__cause__ is None
|
|
558
|
+
assert error.value.__suppress_context__
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def test_http_model_redacts_query_secrets_in_retry_event_messages(monkeypatch):
|
|
562
|
+
monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
|
|
563
|
+
calls = []
|
|
564
|
+
|
|
565
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
566
|
+
calls.append(request)
|
|
567
|
+
if len(calls) == 1:
|
|
568
|
+
raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
|
|
569
|
+
return httpx2.Response(200, json={"status": "OK"})
|
|
570
|
+
|
|
571
|
+
model = HTTPModel(
|
|
572
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
573
|
+
path="/v1/tickers",
|
|
574
|
+
auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
|
|
575
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
|
|
576
|
+
)
|
|
577
|
+
|
|
506
578
|
result = model(HTTPRequestContext())
|
|
507
579
|
|
|
508
|
-
assert result.
|
|
509
|
-
assert result.value is None
|
|
510
|
-
assert result.pages == 2
|
|
580
|
+
assert result.retry_events[0]["message"] == "connection reset for https://api.example.test/v1/tickers?key=***"
|
|
511
581
|
|
|
512
582
|
|
|
513
583
|
def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
|
|
@@ -728,3 +798,67 @@ def test_http_model_ignores_invalid_retry_after(monkeypatch):
|
|
|
728
798
|
model(HTTPRequestContext())
|
|
729
799
|
|
|
730
800
|
assert sleeps == [1.0]
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def test_redaction_policy_matches_normalized_names_substrings_and_patterns():
|
|
804
|
+
policy = RedactionPolicy(names={"X-Session"}, substrings={"Signature"}, patterns=[r"^sig\d+$"], mask="<redacted>")
|
|
805
|
+
|
|
806
|
+
assert policy.is_sensitive("x_session") and policy.is_sensitive("X-SESSION")
|
|
807
|
+
assert policy.is_sensitive("X-Amz-Signature")
|
|
808
|
+
assert policy.is_sensitive("sig2") and not policy.is_sensitive("signal")
|
|
809
|
+
assert not policy.is_sensitive("apiKey")
|
|
810
|
+
assert policy.with_names("apiKey").is_sensitive("API_KEY")
|
|
811
|
+
assert policy.redact_mapping({"X-Session": "s", "date": "d"}) == {"X-Session": "<redacted>", "date": "d"}
|
|
812
|
+
assert policy.redact_url("https://h/p?sig2=a&date=d") == "https://h/p?sig2=<redacted>&date=d"
|
|
813
|
+
assert policy.redact_text("failed for https://h/p?x-session=a&date=d") == "failed for https://h/p?x-session=<redacted>&date=d"
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def test_default_redaction_policy_covers_common_credential_names():
|
|
817
|
+
policy = RedactionPolicy()
|
|
818
|
+
|
|
819
|
+
for name in [
|
|
820
|
+
"Authorization",
|
|
821
|
+
"Proxy-Authorization",
|
|
822
|
+
"Cookie",
|
|
823
|
+
"Set-Cookie",
|
|
824
|
+
"password",
|
|
825
|
+
"apiKey",
|
|
826
|
+
"X-OPENFIGI-APIKEY",
|
|
827
|
+
"page_token",
|
|
828
|
+
"client_secret",
|
|
829
|
+
]:
|
|
830
|
+
assert policy.is_sensitive(name), name
|
|
831
|
+
for name in ["date", "ticker", "X-Request-ID", "limit"]:
|
|
832
|
+
assert not policy.is_sensitive(name), name
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def test_http_model_applies_configured_and_auth_redaction_names_everywhere(caplog, monkeypatch):
|
|
836
|
+
monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
|
|
837
|
+
seen = []
|
|
838
|
+
|
|
839
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
840
|
+
seen.append(request)
|
|
841
|
+
if len(seen) == 1:
|
|
842
|
+
raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
|
|
843
|
+
return httpx2.Response(200, json={"ok": True})
|
|
844
|
+
|
|
845
|
+
model = HTTPModel(
|
|
846
|
+
config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
|
|
847
|
+
path="/v1/items",
|
|
848
|
+
query={"sig": "query-secret", "date": "2025-01-02"},
|
|
849
|
+
headers={"X-Vendor-Session": "header-secret"},
|
|
850
|
+
auth=HTTPAuth(strategy="api_key_header", name="X-Partner", value="auth-secret"),
|
|
851
|
+
redaction=RedactionPolicy(names={"X-Vendor-Session"}, patterns=[r"^sig$"]),
|
|
852
|
+
retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
with caplog.at_level("INFO", logger="httpx2"):
|
|
856
|
+
result = model(HTTPRequestContext())
|
|
857
|
+
dump = safe_request_dump(model.build_request(HTTPRequestContext()))
|
|
858
|
+
|
|
859
|
+
assert seen[-1].headers["X-Partner"] == "auth-secret" and seen[-1].headers["X-Vendor-Session"] == "header-secret"
|
|
860
|
+
assert dump["headers"]["X-Partner"] == "***" and dump["headers"]["X-Vendor-Session"] == "***"
|
|
861
|
+
assert dump["params"] == {"sig": "***", "date": "2025-01-02"}
|
|
862
|
+
assert result.url == "https://api.example.test/v1/items?sig=***&date=2025-01-02"
|
|
863
|
+
assert "query-secret" not in caplog.text
|
|
864
|
+
assert "query-secret" not in result.retry_events[0]["message"]
|
|
@@ -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.4.0"
|
|
16
16
|
requires-python = ">=3.11"
|
|
17
17
|
keywords = []
|
|
18
18
|
|
|
@@ -30,7 +30,7 @@ classifiers = [
|
|
|
30
30
|
|
|
31
31
|
dependencies = [
|
|
32
32
|
"ccflow>=0.8.5",
|
|
33
|
-
"ccflow-etl>=0.
|
|
33
|
+
"ccflow-etl>=0.4.0",
|
|
34
34
|
"httpx2",
|
|
35
35
|
"jinja2",
|
|
36
36
|
]
|
|
@@ -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.4.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
|
|
File without changes
|