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.
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: ccflow-http
3
- Version: 0.1.1
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.1.0
20
+ Requires-Dist: ccflow-etl>=0.4.0
21
21
  Requires-Dist: ccflow>=0.8.5
22
- Requires-Dist: httpx
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 `httpx` transport tests.
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 `httpx` transports or local fixtures. They should not require live network calls or provider credentials.
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 `httpx` transport tests.
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 `httpx` transports or local fixtures. They should not require live network calls or provider credentials.
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).
@@ -0,0 +1,4 @@
1
+ from .base import *
2
+ from .download import *
3
+
4
+ __version__ = "0.3.0"
@@ -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 httpx
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
- def redact_mapping(values: dict[str, Any]) -> dict[str, Any]:
79
- redacted = {}
80
- for key, value in values.items():
81
- normalized_key = key.lower().replace("_", "").replace("-", "")
82
- if normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key:
83
- redacted[key] = "***"
84
- else:
85
- redacted[key] = value
86
- return redacted
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
- return HTTPRequest(
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: httpx.Response) -> Any:
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: httpx.Client, request: HTTPRequest, previous_started_at: float | None
378
- ) -> tuple[httpx.Response, int, list[dict[str, Any]], float]:
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.request(
388
- method=request.method,
389
- url=request.url,
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 httpx.HTTPStatusError as exc:
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
- raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with status {status_label}") from exc
429
- except (httpx.TimeoutException, httpx.ConnectError) as exc:
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
- raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
456
- except httpx.HTTPError as exc:
457
- raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
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 self._is_sensitive_query_param(key):
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
- with httpx.Client(**self._client_kwargs()) as client:
538
- values = []
539
- retry_events = []
540
- total_attempts = 0
541
- pages = 0
542
- previous_started_at = None
543
- while True:
544
- response, attempts, events, previous_started_at = self._request_once(client, request, previous_started_at)
545
- total_attempts += attempts
546
- retry_events.extend(events)
547
- pages += 1
548
- value = self._response_value(response)
549
- values.append(value)
550
-
551
- if not self.paginate or pages >= self.max_pages:
552
- break
553
- next_request = self._next_paginated_request(request, value)
554
- if next_request is None:
555
- break
556
- request = next_request
557
-
558
- return HTTPResult(
559
- value=self._merge_page_values(values) if self.paginate else values[-1],
560
- status_code=response.status_code,
561
- headers=dict(response.headers or {}),
562
- url=str(response.url),
563
- attempts=total_attempts,
564
- pages=pages,
565
- rate_limit=self._rate_limit_headers(dict(response.headers or {})),
566
- retry_events=retry_events,
567
- retry_summary=self._retry_summary(retry_events, attempts=total_attempts, succeeded=True),
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 httpx
9
+ import httpx2
10
10
  import pytest
11
11
  from ccflow_etl import ExecutionPolicy
12
12
 
13
- from ccflow_http import HTTPAuth, HTTPConfig, HTTPModel, HTTPRequest, HTTPRequestContext, HTTPResponseResult, HTTPRetryPolicy, safe_request_dump
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.httpx.Client", FakeClient)
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 = httpx.Request("GET", "https://api.example.test/v1/tickers/AAA?apiKey=secret")
137
- response = httpx.Response(429, request=request)
138
- raise httpx.HTTPStatusError("rate limited with apiKey=secret", request=request, response=response)
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.httpx.Client", FakeClient)
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 = httpx.Request("GET", "https://api.example.test/v1/tickers")
168
- response = httpx.Response(429, request=request, headers={"retry-after": "1"})
169
- raise httpx.HTTPStatusError("rate limited", request=request, response=response)
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.httpx.Client", FakeClient)
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 = httpx.Request("GET", "https://api.example.test/v1/tickers")
199
- response = httpx.Response(503, request=request)
200
- raise httpx.HTTPStatusError("unavailable", request=request, response=response)
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.httpx.Client", FakeClient)
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 = httpx.Request("GET", "https://api.example.test/v1/tickers")
227
- response = httpx.Response(429, request=request)
228
- raise httpx.HTTPStatusError("rate limited", request=request, response=response)
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.httpx.Client", FakeClient)
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 httpx.TimeoutException("timed out")
315
+ raise httpx2.TimeoutException("timed out")
278
316
  return FakeResponse(value={"status": "OK"})
279
317
 
280
- monkeypatch.setattr("ccflow_http.base.httpx.Client", FakeClient)
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 = httpx.Request("GET", "https://api.example.test/v1/tickers?apiKey=secret")
305
- response = httpx.Response(500, request=request)
306
- raise httpx.HTTPStatusError("server error with apiKey=secret", request=request, response=response)
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.httpx.Client", FakeClient)
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.httpx.Client", FakeClient)
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.httpx.Client", FakeClient)
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 test_http_model_sends_next_url_query_and_preserved_auth_with_httpx_transport():
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: httpx.Request) -> httpx.Response:
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 httpx.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v3/reference/tickers?cursor=2"})
388
- return httpx.Response(200, json={"results": [{"ticker": "BBB"}]})
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=httpx.MockTransport(handler)),
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: httpx.Request) -> httpx.Response:
447
+ def handler(request: httpx2.Request) -> httpx2.Response:
410
448
  seen_requests.append(request)
411
- return httpx.Response(200, json={"status": "OK"}, headers={"x-ratelimit-remaining": "7"})
449
+ return httpx2.Response(200, json={"status": "OK"}, headers={"x-ratelimit-remaining": "7"})
412
450
 
413
- transport = httpx.MockTransport(handler)
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: httpx.Request) -> httpx.Response:
444
- return httpx.Response(200, text="ticker,volume\nAAA,10\nBBB,20\n")
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=httpx.MockTransport(csv_handler)),
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: httpx.Request) -> httpx.Response:
453
- return httpx.Response(200, content=compress(b'{"status":"OK"}'))
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=httpx.MockTransport(gzip_handler)),
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: httpx.Request) -> httpx.Response:
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 httpx.Response(200, json={"results": [{"id": 1}], "next_cursor": "abc"})
472
- return httpx.Response(200, json={"results": [{"id": 2}]})
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=httpx.MockTransport(cursor_handler)),
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: httpx.Request) -> httpx.Response:
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 httpx.Response(200, json={"results": payload})
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=httpx.MockTransport(page_handler)),
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: httpx.Request) -> httpx.Response:
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 httpx.Response(200, json={"results": payload})
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=httpx.MockTransport(offset_handler)),
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 = httpx.Request("GET", "https://api.example.test/v1/tickers")
550
- response = httpx.Response(status_code, request=request, headers=headers or {})
551
- raise httpx.HTTPStatusError("error", request=request, response=response)
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
- def test_http_model_retries_connect_error(monkeypatch):
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 httpx.ConnectError("connection failed")
699
+ raise exception_type("transport failed")
559
700
 
560
- monkeypatch.setattr("ccflow_http.base.httpx.Client", _retrying_client(calls, fail_once))
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"] == "connection"
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.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "45"})))
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.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "1"})))
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.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "9999"})))
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.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": retry_at})))
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.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "soon"})))
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.1.1"
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.1.0",
34
- "httpx",
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.1.1"
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]
@@ -1,3 +0,0 @@
1
- from .base import *
2
-
3
- __version__ = "0.1.1"
File without changes