ccflow-http 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2.0
4
4
  Summary: ccflow models for HTTP
5
5
  Project-URL: Repository, https://github.com/1kbgz/ccflow-http
6
6
  Project-URL: Homepage, https://github.com/1kbgz/ccflow-http
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
19
19
  Requires-Python: >=3.11
20
20
  Requires-Dist: ccflow-etl>=0.1.0
21
21
  Requires-Dist: ccflow>=0.8.5
22
- Requires-Dist: httpx
22
+ Requires-Dist: httpx2
23
23
  Requires-Dist: jinja2
24
24
  Provides-Extra: develop
25
25
  Requires-Dist: build; extra == 'develop'
@@ -55,7 +55,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
55
55
 
56
56
  ## Current Status
57
57
 
58
- - Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, and mocked `httpx` transport tests.
58
+ - Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, accepted error statuses returned as results instead of exceptions, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, redaction of secret query values from `httpx2` request logs, result URLs, and status errors, and mocked `httpx2` transport tests.
59
59
  - Partial: the shared execution policy is currently consumed inside `HTTPModel` for sequential request spacing; broader evaluator-level concurrency coordination still belongs in evaluator integrations.
60
60
  - Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
61
61
 
@@ -67,7 +67,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
67
67
 
68
68
  ## Test Convention
69
69
 
70
- Default tests should use mocked `httpx` transports or local fixtures. They should not require live network calls or provider credentials.
70
+ Default tests should use mocked `httpx2` transports or local fixtures. They should not require live network calls or provider credentials.
71
71
 
72
72
  > [!NOTE]
73
73
  > This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).
@@ -15,7 +15,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
15
15
 
16
16
  ## Current Status
17
17
 
18
- - Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, and mocked `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 from `httpx2` request logs, result URLs, and status errors, and mocked `httpx2` transport tests.
19
19
  - Partial: the shared execution policy is currently consumed inside `HTTPModel` for sequential request spacing; broader evaluator-level concurrency coordination still belongs in evaluator integrations.
20
20
  - Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
21
21
 
@@ -27,7 +27,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
27
27
 
28
28
  ## Test Convention
29
29
 
30
- Default tests should use mocked `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,3 @@
1
+ from .base import *
2
+
3
+ __version__ = "0.2.0"
@@ -4,11 +4,12 @@ from datetime import UTC, datetime
4
4
  from email.utils import parsedate_to_datetime
5
5
  from gzip import decompress
6
6
  from io import StringIO
7
+ from logging import Filter, LogRecord, getLogger
7
8
  from time import monotonic, sleep
8
9
  from typing import Any, Literal
9
- from urllib.parse import parse_qsl, urlsplit, urlunsplit
10
+ from urllib.parse import parse_qsl, unquote_plus, urlsplit, urlunsplit
10
11
 
11
- import httpx
12
+ import httpx2
12
13
  from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, PyObjectPath
13
14
  from ccflow.utils.retry import RetryPolicy
14
15
  from ccflow_etl import ExecutionPolicy
@@ -26,6 +27,7 @@ __all__ = (
26
27
  "HTTPResult",
27
28
  "HTTPRetryPolicy",
28
29
  "redact_mapping",
30
+ "redact_url",
29
31
  "safe_request_dump",
30
32
  )
31
33
 
@@ -75,15 +77,36 @@ class HTTPRequest(BaseModel):
75
77
  content: bytes | str | None = None
76
78
 
77
79
 
80
+ def _is_sensitive_key(key: str) -> bool:
81
+ normalized_key = key.lower().replace("_", "").replace("-", "")
82
+ return normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
83
+
84
+
78
85
  def redact_mapping(values: dict[str, Any]) -> dict[str, Any]:
79
- 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
86
+ return {key: "***" if _is_sensitive_key(key) else value for key, value in values.items()}
87
+
88
+
89
+ def redact_url(url: str) -> str:
90
+ parts = urlsplit(url)
91
+ if not parts.query:
92
+ return url
93
+ pairs = []
94
+ for pair in parts.query.split("&"):
95
+ key = pair.split("=", 1)[0]
96
+ pairs.append(f"{key}=***" if _is_sensitive_key(unquote_plus(key)) else pair)
97
+ return urlunsplit(parts._replace(query="&".join(pairs)))
98
+
99
+
100
+ class _RedactURLFilter(Filter):
101
+ """Redact secret query values from URLs that httpx2 logs for each request."""
102
+
103
+ def filter(self, record: LogRecord) -> bool:
104
+ if isinstance(record.args, tuple):
105
+ record.args = tuple(redact_url(str(arg)) if isinstance(arg, httpx2.URL) else arg for arg in record.args)
106
+ return True
107
+
108
+
109
+ getLogger("httpx2").addFilter(_RedactURLFilter())
87
110
 
88
111
 
89
112
  def safe_request_dump(request: HTTPRequest) -> dict[str, Any]:
@@ -105,9 +128,7 @@ class HTTPRetryEvent(BaseModel):
105
128
 
106
129
  class HTTPRetryPolicy(RetryPolicy):
107
130
  retry_status_codes: list[int] = Field(default_factory=lambda: [429, 500, 502, 503, 504])
108
- retry_exceptions: list[PyObjectPath] = Field(
109
- default_factory=lambda: [PyObjectPath.validate(httpx.TimeoutException), PyObjectPath.validate(httpx.ConnectError)]
110
- )
131
+ retry_exceptions: list[PyObjectPath] = Field(default_factory=lambda: [PyObjectPath.validate(httpx2.TransportError)])
111
132
  timeout_exception_types: list[str] = Field(
112
133
  default_factory=lambda: ["TimeoutError", "TimeoutException", "ConnectTimeout", "ReadTimeout", "WriteTimeout", "PoolTimeout"]
113
134
  )
@@ -199,6 +220,10 @@ class HTTPModel(CallableModel):
199
220
  max_attempts: int = 1
200
221
  retry_status_codes: list[int] = Field(default_factory=lambda: [429, 500, 502, 503, 504])
201
222
  retry_policy: HTTPRetryPolicy | None = None
223
+ accepted_status_codes: list[int] = Field(
224
+ default_factory=list,
225
+ description="Error status codes returned as results with value None instead of raising, such as 404 for lookups that may miss.",
226
+ )
202
227
  execution_policy: ExecutionPolicy | None = None
203
228
  paginate: bool = False
204
229
  max_pages: int = 100
@@ -309,7 +334,7 @@ class HTTPModel(CallableModel):
309
334
  content=context.content if context.content is not None else self.content,
310
335
  )
311
336
 
312
- def _response_value(self, response: httpx.Response) -> Any:
337
+ def _response_value(self, response: httpx2.Response) -> Any:
313
338
  match self.response_format:
314
339
  case "json":
315
340
  return response.json()
@@ -374,8 +399,8 @@ class HTTPModel(CallableModel):
374
399
  return current
375
400
 
376
401
  def _request_once(
377
- self, client: httpx.Client, request: HTTPRequest, previous_started_at: float | None
378
- ) -> tuple[httpx.Response, int, list[dict[str, Any]], float]:
402
+ self, client: httpx2.Client, request: HTTPRequest, previous_started_at: float | None
403
+ ) -> tuple[httpx2.Response, int, list[dict[str, Any]], float]:
379
404
  attempts = 0
380
405
  events: list[dict[str, Any]] = []
381
406
  retry_policy = self._retry_policy()
@@ -392,9 +417,11 @@ class HTTPModel(CallableModel):
392
417
  json=request.json_data,
393
418
  content=request.content,
394
419
  )
420
+ if response.status_code in self.accepted_status_codes:
421
+ return response, attempts, events, previous_started_at
395
422
  response.raise_for_status()
396
423
  return response, attempts, events, previous_started_at
397
- except httpx.HTTPStatusError as exc:
424
+ except httpx2.HTTPStatusError as exc:
398
425
  status_code = exc.response.status_code if exc.response is not None else None
399
426
  if retry_policy.should_retry_status(status_code, attempts):
400
427
  delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
@@ -425,8 +452,9 @@ class HTTPModel(CallableModel):
425
452
  )
426
453
  )
427
454
  status_label = status_code if status_code is not None else "unknown"
428
- 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:
455
+ # httpx2 status errors embed the full request URL, including secret query values.
456
+ raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with status {status_label}") from None
457
+ except httpx2.TransportError as exc:
430
458
  if retry_policy.should_retry_exception(exc, attempts):
431
459
  delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
432
460
  if delay_seconds is not None:
@@ -453,7 +481,7 @@ class HTTPModel(CallableModel):
453
481
  )
454
482
  )
455
483
  raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
456
- except httpx.HTTPError as exc:
484
+ except httpx2.HTTPError as exc:
457
485
  raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
458
486
 
459
487
  def _merge_page_values(self, values: list[Any]) -> Any:
@@ -474,15 +502,11 @@ class HTTPModel(CallableModel):
474
502
  def _request_with_params(self, request: HTTPRequest, params: dict[str, Any]) -> HTTPRequest:
475
503
  return request.model_copy(update={"params": {**request.params, **params}})
476
504
 
477
- def _is_sensitive_query_param(self, key: str) -> bool:
478
- normalized_key = key.lower().replace("_", "").replace("-", "")
479
- return normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
480
-
481
505
  def _next_url_request(self, request: HTTPRequest, next_url: str) -> HTTPRequest:
482
506
  next_url_parts = urlsplit(next_url)
483
507
  next_url_params = dict(parse_qsl(next_url_parts.query, keep_blank_values=True))
484
508
  for key, value in request.params.items():
485
- if key not in next_url_params and self._is_sensitive_query_param(key):
509
+ if key not in next_url_params and _is_sensitive_key(key):
486
510
  next_url_params[key] = value
487
511
  next_url_without_query = urlunsplit((next_url_parts.scheme, next_url_parts.netloc, next_url_parts.path, "", next_url_parts.fragment))
488
512
  return request.model_copy(update={"url": next_url_without_query, "params": next_url_params})
@@ -534,7 +558,7 @@ class HTTPModel(CallableModel):
534
558
  def __call__(self, context: HTTPRequestContext) -> HTTPResult:
535
559
  request = self._initial_paginated_request(self.build_request(context))
536
560
 
537
- with httpx.Client(**self._client_kwargs()) as client:
561
+ with httpx2.Client(**self._client_kwargs()) as client:
538
562
  values = []
539
563
  retry_events = []
540
564
  total_attempts = 0
@@ -545,6 +569,9 @@ class HTTPModel(CallableModel):
545
569
  total_attempts += attempts
546
570
  retry_events.extend(events)
547
571
  pages += 1
572
+ accepted_error = response.status_code in self.accepted_status_codes
573
+ if accepted_error:
574
+ break
548
575
  value = self._response_value(response)
549
576
  values.append(value)
550
577
 
@@ -556,10 +583,10 @@ class HTTPModel(CallableModel):
556
583
  request = next_request
557
584
 
558
585
  return HTTPResult(
559
- value=self._merge_page_values(values) if self.paginate else values[-1],
586
+ value=None if accepted_error else (self._merge_page_values(values) if self.paginate else values[-1]),
560
587
  status_code=response.status_code,
561
588
  headers=dict(response.headers or {}),
562
- url=str(response.url),
589
+ url=redact_url(str(response.url)),
563
590
  attempts=total_attempts,
564
591
  pages=pages,
565
592
  rate_limit=self._rate_limit_headers(dict(response.headers or {})),
@@ -6,11 +6,21 @@ from datetime import UTC
6
6
  from gzip import compress
7
7
  from typing import Any
8
8
 
9
- import 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,54 @@ def test_http_model_applies_config_and_all_auth_strategies_with_mock_transport()
439
477
  assert "authorization" not in seen_requests[4].headers
440
478
 
441
479
 
480
+ def test_http_model_returns_accepted_error_status_without_raising():
481
+ transport = httpx2.MockTransport(lambda request: httpx2.Response(404, json={"status": "NOT_FOUND"}))
482
+ config = HTTPConfig(base_url="https://api.example.test", transport=transport)
483
+
484
+ result = HTTPModel(config=config, path="/v1/tickers/ZZZZ", accepted_status_codes=[404])(HTTPRequestContext())
485
+
486
+ assert result.status_code == 404
487
+ assert result.value is None
488
+ assert result.retry_summary == {"attempts": 1, "retried": 0, "failed": 0, "succeeded": 1}
489
+ with pytest.raises(RuntimeError, match="failed with status 404"):
490
+ HTTPModel(config=config, path="/v1/tickers/ZZZZ")(HTTPRequestContext())
491
+
492
+
493
+ def test_http_model_stops_pagination_on_accepted_error_status():
494
+ def handler(request: httpx2.Request) -> httpx2.Response:
495
+ if "cursor" in request.url.params:
496
+ return httpx2.Response(404)
497
+ return httpx2.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v1/tickers?cursor=2"})
498
+
499
+ model = HTTPModel(
500
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
501
+ path="/v1/tickers",
502
+ paginate=True,
503
+ accepted_status_codes=[404],
504
+ )
505
+
506
+ result = model(HTTPRequestContext())
507
+
508
+ assert result.status_code == 404
509
+ assert result.value is None
510
+ assert result.pages == 2
511
+
512
+
442
513
  def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
443
- def csv_handler(request: httpx.Request) -> httpx.Response:
444
- return httpx.Response(200, text="ticker,volume\nAAA,10\nBBB,20\n")
514
+ def csv_handler(request: httpx2.Request) -> httpx2.Response:
515
+ return httpx2.Response(200, text="ticker,volume\nAAA,10\nBBB,20\n")
445
516
 
446
517
  csv_result = HTTPModel(
447
- config=HTTPConfig(base_url="https://api.example.test", transport=httpx.MockTransport(csv_handler)),
518
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(csv_handler)),
448
519
  path="/csv",
449
520
  response_format="csv",
450
521
  )(HTTPRequestContext())
451
522
 
452
- def gzip_handler(request: httpx.Request) -> httpx.Response:
453
- return httpx.Response(200, content=compress(b'{"status":"OK"}'))
523
+ def gzip_handler(request: httpx2.Request) -> httpx2.Response:
524
+ return httpx2.Response(200, content=compress(b'{"status":"OK"}'))
454
525
 
455
526
  gzip_result = HTTPModel(
456
- config=HTTPConfig(base_url="https://api.example.test", transport=httpx.MockTransport(gzip_handler)),
527
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(gzip_handler)),
457
528
  path="/gzip",
458
529
  response_format="gzip",
459
530
  )(HTTPRequestContext())
@@ -465,14 +536,14 @@ def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
465
536
  def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transport():
466
537
  cursor_requests = []
467
538
 
468
- def cursor_handler(request: httpx.Request) -> httpx.Response:
539
+ def cursor_handler(request: httpx2.Request) -> httpx2.Response:
469
540
  cursor_requests.append(dict(request.url.params))
470
541
  if "cursor" not in request.url.params:
471
- return httpx.Response(200, json={"results": [{"id": 1}], "next_cursor": "abc"})
472
- return httpx.Response(200, json={"results": [{"id": 2}]})
542
+ return httpx2.Response(200, json={"results": [{"id": 1}], "next_cursor": "abc"})
543
+ return httpx2.Response(200, json={"results": [{"id": 2}]})
473
544
 
474
545
  cursor_result = HTTPModel(
475
- config=HTTPConfig(base_url="https://api.example.test", transport=httpx.MockTransport(cursor_handler)),
546
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(cursor_handler)),
476
547
  path="/items",
477
548
  paginate=True,
478
549
  pagination_mode="cursor",
@@ -482,14 +553,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
482
553
 
483
554
  page_requests = []
484
555
 
485
- def page_handler(request: httpx.Request) -> httpx.Response:
556
+ def page_handler(request: httpx2.Request) -> httpx2.Response:
486
557
  page_requests.append(dict(request.url.params))
487
558
  page = int(request.url.params["page"])
488
559
  payload = {1: [{"id": 1}], 2: [{"id": 2}]}.get(page, [])
489
- return httpx.Response(200, json={"results": payload})
560
+ return httpx2.Response(200, json={"results": payload})
490
561
 
491
562
  page_result = HTTPModel(
492
- config=HTTPConfig(base_url="https://api.example.test", transport=httpx.MockTransport(page_handler)),
563
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(page_handler)),
493
564
  path="/items",
494
565
  paginate=True,
495
566
  pagination_mode="page",
@@ -500,14 +571,14 @@ def test_http_model_supports_cursor_page_and_offset_pagination_with_mock_transpo
500
571
 
501
572
  offset_requests = []
502
573
 
503
- def offset_handler(request: httpx.Request) -> httpx.Response:
574
+ def offset_handler(request: httpx2.Request) -> httpx2.Response:
504
575
  offset_requests.append(dict(request.url.params))
505
576
  offset = int(request.url.params["offset"])
506
577
  payload = {0: [{"id": 1}], 2: [{"id": 2}]}.get(offset, [])
507
- return httpx.Response(200, json={"results": payload})
578
+ return httpx2.Response(200, json={"results": payload})
508
579
 
509
580
  offset_result = HTTPModel(
510
- config=HTTPConfig(base_url="https://api.example.test", transport=httpx.MockTransport(offset_handler)),
581
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(offset_handler)),
511
582
  path="/items",
512
583
  paginate=True,
513
584
  pagination_mode="offset",
@@ -546,29 +617,30 @@ def _retrying_client(calls, response_factory):
546
617
 
547
618
 
548
619
  def _raise_status(status_code, headers=None):
549
- request = 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)
620
+ request = httpx2.Request("GET", "https://api.example.test/v1/tickers")
621
+ response = httpx2.Response(status_code, request=request, headers=headers or {})
622
+ raise httpx2.HTTPStatusError("error", request=request, response=response)
552
623
 
553
624
 
554
- def test_http_model_retries_connect_error(monkeypatch):
625
+ @pytest.mark.parametrize(("exception_type", "category"), [(httpx2.ConnectError, "connection"), (httpx2.RemoteProtocolError, "exception")])
626
+ def test_http_model_retries_transport_error(monkeypatch, exception_type, category):
555
627
  calls = []
556
628
 
557
629
  def fail_once():
558
- raise httpx.ConnectError("connection failed")
630
+ raise exception_type("transport failed")
559
631
 
560
- monkeypatch.setattr("ccflow_http.base.httpx.Client", _retrying_client(calls, fail_once))
632
+ monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, fail_once))
561
633
 
562
634
  result = HTTPModel(base_url="https://api.example.test", path="/v1/tickers", max_attempts=2)(HTTPRequestContext())
563
635
 
564
636
  assert len(calls) == 2
565
637
  assert result.attempts == 2
566
- assert result.retry_events[0]["category"] == "connection"
638
+ assert result.retry_events[0]["category"] == category
567
639
 
568
640
 
569
641
  def test_http_model_honors_retry_after_seconds_over_backoff(monkeypatch):
570
642
  calls = []
571
- monkeypatch.setattr("ccflow_http.base.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "45"})))
643
+ monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "45"})))
572
644
 
573
645
  model = HTTPModel(
574
646
  base_url="https://api.example.test",
@@ -587,7 +659,7 @@ def test_http_model_honors_retry_after_seconds_over_backoff(monkeypatch):
587
659
 
588
660
  def test_http_model_uses_backoff_when_it_exceeds_retry_after(monkeypatch):
589
661
  calls = []
590
- monkeypatch.setattr("ccflow_http.base.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "1"})))
662
+ monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "1"})))
591
663
 
592
664
  model = HTTPModel(
593
665
  base_url="https://api.example.test",
@@ -604,7 +676,7 @@ def test_http_model_uses_backoff_when_it_exceeds_retry_after(monkeypatch):
604
676
 
605
677
  def test_http_model_caps_retry_after_at_retry_after_max(monkeypatch):
606
678
  calls = []
607
- monkeypatch.setattr("ccflow_http.base.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "9999"})))
679
+ monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "9999"})))
608
680
 
609
681
  model = HTTPModel(
610
682
  base_url="https://api.example.test",
@@ -625,7 +697,7 @@ def test_http_model_honors_retry_after_http_date(monkeypatch):
625
697
 
626
698
  calls = []
627
699
  retry_at = format_datetime(datetime.now(UTC) + timedelta(seconds=60))
628
- monkeypatch.setattr("ccflow_http.base.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": retry_at})))
700
+ monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": retry_at})))
629
701
 
630
702
  model = HTTPModel(
631
703
  base_url="https://api.example.test",
@@ -643,7 +715,7 @@ def test_http_model_honors_retry_after_http_date(monkeypatch):
643
715
 
644
716
  def test_http_model_ignores_invalid_retry_after(monkeypatch):
645
717
  calls = []
646
- monkeypatch.setattr("ccflow_http.base.httpx.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "soon"})))
718
+ monkeypatch.setattr("ccflow_http.base.httpx2.Client", _retrying_client(calls, lambda: _raise_status(429, {"retry-after": "soon"})))
647
719
 
648
720
  model = HTTPModel(
649
721
  base_url="https://api.example.test",
@@ -12,7 +12,7 @@ authors = [
12
12
  description = "ccflow models for HTTP"
13
13
  readme = "README.md"
14
14
  license = { text = "Apache-2.0" }
15
- version = "0.1.1"
15
+ version = "0.2.0"
16
16
  requires-python = ">=3.11"
17
17
  keywords = []
18
18
 
@@ -31,7 +31,7 @@ classifiers = [
31
31
  dependencies = [
32
32
  "ccflow>=0.8.5",
33
33
  "ccflow-etl>=0.1.0",
34
- "httpx",
34
+ "httpx2",
35
35
  "jinja2",
36
36
  ]
37
37
 
@@ -60,7 +60,7 @@ Repository = "https://github.com/1kbgz/ccflow-http"
60
60
  Homepage = "https://github.com/1kbgz/ccflow-http"
61
61
 
62
62
  [tool.bumpversion]
63
- current_version = "0.1.1"
63
+ current_version = "0.2.0"
64
64
  commit = true
65
65
  tag = true
66
66
  commit_args = "-s"
@@ -1,3 +0,0 @@
1
- from .base import *
2
-
3
- __version__ = "0.1.1"
File without changes