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

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