csrd-delegate 0.1.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.
@@ -0,0 +1,217 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ /env/
142
+ /venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+
209
+ *.db
210
+
211
+
212
+ # Import linter cache
213
+ .import_linter_cache/
214
+
215
+ # IDE
216
+ .idea/
217
+ .idea/*
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: csrd-delegate
3
+ Version: 0.1.0
4
+ Summary: HTTP delegate base class with retry support
5
+ Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
+ Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/delegate
7
+ Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
8
+ License: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: csrd-context
11
+ Requires-Dist: csrd-models
12
+ Requires-Dist: fastapi<1,>=0.115
13
+ Requires-Dist: httpx<1,>=0.28
14
+ Requires-Dist: tenacity<10,>=9.1
15
+ Requires-Dist: yarl<2,>=1.20
@@ -0,0 +1,22 @@
1
+ # csrd-delegate
2
+
3
+ HTTP client delegate base class with retry support for FastAPI microservices.
4
+
5
+ **Package**: `csrd.delegate` · **Import**: `from csrd.delegate import BaseDelegate`
6
+
7
+ ## What's included
8
+
9
+ - `BaseDelegate` — async HTTP client with header forwarding, retry via tenacity, and lifecycle (`close()` / `async with`)
10
+ - Response parsing via `csrd.models.model_parser`
11
+ - Configurable retry profiles (`conservative`, `aggressive`, `resilient`)
12
+ - httpx-specific response types (`ResponseHandler`, `ResponseHandlerMap`)
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ uv pip install "csrd-delegate @ git+ssh://git@github.com/csrd-api/fastapi-common.git#subdirectory=packages/delegate"
18
+ ```
19
+
20
+ ## Dependencies
21
+
22
+ - `csrd-models`, `csrd-context` (Tier 2)
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "csrd-delegate"
3
+ version = "0.1.0"
4
+ description = "HTTP delegate base class with retry support"
5
+ license = { text = "MIT" }
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "httpx>=0.28,<1",
9
+ "yarl>=1.20,<2",
10
+ "tenacity>=9.1,<10",
11
+ "fastapi>=0.115,<1",
12
+ "csrd-context",
13
+ "csrd-models",
14
+ ]
15
+
16
+ [tool.uv.sources]
17
+ csrd-context = { workspace = true }
18
+ csrd-models = { workspace = true }
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/csrd-api/fastapi-common"
22
+ Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/delegate"
23
+ Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/csrd"]
@@ -0,0 +1,4 @@
1
+ from ._base_delegate import BaseDelegate
2
+ from ._retry import RetryProfile
3
+
4
+ __all__ = ("BaseDelegate", "RetryProfile")
@@ -0,0 +1,566 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from contextlib import asynccontextmanager
4
+ from copy import deepcopy
5
+ from inspect import signature
6
+ from types import TracebackType
7
+ from typing import Any, Self
8
+
9
+ import httpx
10
+ from fastapi import HTTPException
11
+ from httpx import AsyncClient, ConnectError, Response, TimeoutException
12
+ from httpx._client import USE_CLIENT_DEFAULT, UseClientDefault
13
+ from httpx._types import (
14
+ AuthTypes,
15
+ CookieTypes,
16
+ HeaderTypes,
17
+ QueryParamTypes,
18
+ RequestContent,
19
+ RequestData,
20
+ RequestExtensions,
21
+ RequestFiles,
22
+ TimeoutTypes,
23
+ URLTypes,
24
+ )
25
+ from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_attempt, wait_exponential
26
+ from yarl import URL
27
+
28
+ from csrd.context import get_headers
29
+ from csrd.delegate._response_types import ModelHandler, ResponseHandlerMap
30
+ from csrd.delegate._retry import RETRY_PROFILES, RetryProfiles
31
+ from csrd.models.model_parser import ModelParserMixin, ParsedResponse, ResponseModelType
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class BaseDelegate(ModelParserMixin):
37
+ """
38
+ A base HTTP delegate class for managing outbound HTTP requests with retry logic,
39
+ dynamic response parsing, and FastAPI integration.
40
+ """
41
+
42
+ _filter_headers_list: list[str]
43
+ _client: AsyncClient
44
+ _base_url: URL
45
+ _ignore_incoming_headers: bool
46
+
47
+ _retry_enabled: bool
48
+ _retry_profile: RetryProfiles | None
49
+ _retry_attempts: int
50
+ _retry_backoff: float
51
+
52
+ _owns_client: bool
53
+
54
+ def __init__(
55
+ self,
56
+ service_host: str | URL,
57
+ *,
58
+ client: AsyncClient | None = None,
59
+ header_filter_list: list[str] | None = None,
60
+ ignore_incoming_headers=False,
61
+ retry_enabled: bool = False,
62
+ retry_profile: RetryProfiles | None = None,
63
+ retry_attempts: int = 3,
64
+ retry_backoff: float = 0.2,
65
+ ):
66
+ super().__init__()
67
+ self._base_url = URL(service_host)
68
+ self._owns_client = client is None
69
+ self._client = client or AsyncClient(base_url=str(self._base_url))
70
+
71
+ self._filter_headers_list = header_filter_list or ["host"]
72
+ self._ignore_incoming_headers = ignore_incoming_headers
73
+
74
+ self._retry_enabled = retry_enabled or (retry_profile is not None)
75
+ self._retry_profile = retry_profile
76
+ self._retry_attempts = retry_attempts
77
+ self._retry_backoff = retry_backoff
78
+
79
+ # ── Lifecycle ────────────────────────────────────────────────────────
80
+
81
+ async def close(self) -> None:
82
+ """Close the underlying HTTP client if this delegate owns it.
83
+
84
+ Safe to call multiple times. If the caller injected an external
85
+ ``AsyncClient``, this is a no-op (the caller owns that client).
86
+ """
87
+ if self._owns_client and not self._client.is_closed:
88
+ await self._client.aclose()
89
+
90
+ async def __aenter__(self) -> Self:
91
+ return self
92
+
93
+ async def __aexit__(
94
+ self,
95
+ exc_type: type[BaseException] | None,
96
+ exc_val: BaseException | None,
97
+ exc_tb: TracebackType | None,
98
+ ) -> None:
99
+ await self.close()
100
+
101
+ @asynccontextmanager
102
+ async def _request_context(self, method: Callable, **kwargs):
103
+ headers = self._headers or {}
104
+ headers = self._normalize_headers(headers)
105
+
106
+ if kwargs.get("headers"):
107
+ user_headers = self._normalize_headers(kwargs["headers"])
108
+ headers.update(user_headers)
109
+
110
+ kwargs["headers"] = self._filter_headers(headers)
111
+ retry_config = self._parse_retry_profile(**kwargs)
112
+
113
+ kwargs = self._filter_method_kwargs(method, **kwargs)
114
+ try:
115
+ response = await self._call_with_optional_retry(method, **kwargs, **retry_config)
116
+ yield response
117
+ finally:
118
+ pass
119
+
120
+ async def _call_with_optional_retry(
121
+ self,
122
+ method: Callable,
123
+ *,
124
+ retry_enabled: bool | None = None,
125
+ retry_attempts: int | None = None,
126
+ retry_backoff_base: float | None = None,
127
+ **kwargs,
128
+ ) -> Response:
129
+ use_retry = self._retry_enabled if retry_enabled is None else retry_enabled
130
+ attempts = retry_attempts or self._retry_attempts
131
+ backoff = retry_backoff_base or self._retry_backoff
132
+ kwargs = self._filter_method_kwargs(method, **kwargs)
133
+
134
+ async def _do_req(**kwargs_) -> Response:
135
+ try:
136
+ response_ = await method(**kwargs_)
137
+ return self._parse_status_code(
138
+ response_, response_handlers=kwargs_.get("response_handlers")
139
+ )
140
+ except Exception as e_:
141
+ ex = HTTPException(
142
+ status_code=getattr(e_, "status_code", 500), detail=getattr(e_, "detail", None)
143
+ )
144
+ logger.warning(ex)
145
+ raise ex from e_
146
+
147
+ if not use_retry:
148
+ return await _do_req(**kwargs)
149
+
150
+ async for attempt in AsyncRetrying(
151
+ stop=stop_after_attempt(attempts),
152
+ wait=wait_exponential(multiplier=backoff),
153
+ retry=retry_if_exception_type(
154
+ (HTTPException, TimeoutException, ConnectError, httpx.HTTPError)
155
+ ),
156
+ reraise=True,
157
+ ):
158
+ with attempt:
159
+ logger.info(f"[attempt {attempt.retry_state.attempt_number}]")
160
+ return await _do_req(**kwargs)
161
+ raise RuntimeError("Unreachable: retry loop exhausted without reraise")
162
+
163
+ async def _request(
164
+ self,
165
+ method: Callable,
166
+ *,
167
+ url: URLTypes,
168
+ params: QueryParamTypes | None = None,
169
+ headers: HeaderTypes | None = None,
170
+ cookies: CookieTypes | None = None,
171
+ auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
172
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
173
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
174
+ extensions: RequestExtensions | None = None,
175
+ content: RequestContent | None = None,
176
+ data: RequestData | None = None,
177
+ files: RequestFiles | None = None,
178
+ json: Any | None = None,
179
+ model_handler: ModelHandler | None = None,
180
+ response_model: ResponseModelType | None = None,
181
+ response_handlers: ResponseHandlerMap | None = None,
182
+ retry_enabled: bool | None = None,
183
+ retry_profile: RetryProfiles | None = None,
184
+ retry_attempts: int | None = None,
185
+ retry_backoff: float | None = None,
186
+ ) -> ParsedResponse:
187
+ async with self._request_context(
188
+ method,
189
+ url=url,
190
+ params=params,
191
+ headers=headers,
192
+ cookies=cookies,
193
+ auth=auth,
194
+ follow_redirects=follow_redirects,
195
+ timeout=timeout,
196
+ extensions=extensions,
197
+ content=content,
198
+ data=data,
199
+ files=files,
200
+ json=json,
201
+ retry_enabled=retry_enabled,
202
+ retry_profile=retry_profile,
203
+ retry_attempts=retry_attempts,
204
+ retry_backoff=retry_backoff,
205
+ ) as response:
206
+ response = self._parse_status_code(response, response_handlers=response_handlers)
207
+ return self.apply_model(response, model=response_model, model_handler=model_handler)
208
+
209
+ # Public HTTP methods
210
+ async def get(
211
+ self,
212
+ url: URLTypes,
213
+ *,
214
+ params: QueryParamTypes | None = None,
215
+ headers: HeaderTypes | None = None,
216
+ cookies: CookieTypes | None = None,
217
+ auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
218
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
219
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
220
+ extensions: RequestExtensions | None = None,
221
+ model_handler: ModelHandler | None = None,
222
+ response_model: ResponseModelType | None = None,
223
+ response_handlers: ResponseHandlerMap | None = None,
224
+ retry_enabled: bool | None = None,
225
+ retry_profile: RetryProfiles | None = None,
226
+ retry_attempts: int | None = None,
227
+ retry_backoff: float | None = None,
228
+ ) -> ParsedResponse:
229
+ return await self._request(
230
+ self._client.get,
231
+ url=url,
232
+ params=params,
233
+ headers=headers,
234
+ cookies=cookies,
235
+ auth=auth,
236
+ follow_redirects=follow_redirects,
237
+ timeout=timeout,
238
+ extensions=extensions,
239
+ model_handler=model_handler,
240
+ response_model=response_model,
241
+ response_handlers=response_handlers,
242
+ retry_enabled=retry_enabled,
243
+ retry_profile=retry_profile,
244
+ retry_attempts=retry_attempts,
245
+ retry_backoff=retry_backoff,
246
+ )
247
+
248
+ async def delete(
249
+ self,
250
+ url: URLTypes,
251
+ *,
252
+ params: QueryParamTypes | None = None,
253
+ headers: HeaderTypes | None = None,
254
+ cookies: CookieTypes | None = None,
255
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
256
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
257
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
258
+ extensions: RequestExtensions | None = None,
259
+ model_handler: ModelHandler | None = None,
260
+ response_model: ResponseModelType | None = None,
261
+ response_handlers: ResponseHandlerMap | None = None,
262
+ retry_enabled: bool | None = None,
263
+ retry_profile: RetryProfiles | None = None,
264
+ retry_attempts: int | None = None,
265
+ retry_backoff: float | None = None,
266
+ ) -> Any | bytes | dict | None:
267
+ return await self._request(
268
+ self._client.delete,
269
+ url=url,
270
+ params=params,
271
+ headers=headers,
272
+ cookies=cookies,
273
+ auth=auth,
274
+ follow_redirects=follow_redirects,
275
+ timeout=timeout,
276
+ extensions=extensions,
277
+ model_handler=model_handler,
278
+ response_model=response_model,
279
+ response_handlers=response_handlers,
280
+ retry_enabled=retry_enabled,
281
+ retry_profile=retry_profile,
282
+ retry_attempts=retry_attempts,
283
+ retry_backoff=retry_backoff,
284
+ )
285
+
286
+ async def head(
287
+ self,
288
+ url: URLTypes,
289
+ *,
290
+ params: QueryParamTypes | None = None,
291
+ headers: HeaderTypes | None = None,
292
+ cookies: CookieTypes | None = None,
293
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
294
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
295
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
296
+ extensions: RequestExtensions | None = None,
297
+ model_handler: ModelHandler | None = None,
298
+ response_model: ResponseModelType | None = None,
299
+ response_handlers: ResponseHandlerMap | None = None,
300
+ retry_enabled: bool | None = None,
301
+ retry_profile: RetryProfiles | None = None,
302
+ retry_attempts: int | None = None,
303
+ retry_backoff: float | None = None,
304
+ ) -> ParsedResponse:
305
+ return await self._request(
306
+ self._client.head,
307
+ url=url,
308
+ params=params,
309
+ headers=headers,
310
+ cookies=cookies,
311
+ auth=auth,
312
+ follow_redirects=follow_redirects,
313
+ timeout=timeout,
314
+ extensions=extensions,
315
+ model_handler=model_handler,
316
+ response_model=response_model,
317
+ response_handlers=response_handlers,
318
+ retry_enabled=retry_enabled,
319
+ retry_profile=retry_profile,
320
+ retry_attempts=retry_attempts,
321
+ retry_backoff=retry_backoff,
322
+ )
323
+
324
+ async def post(
325
+ self,
326
+ url: URLTypes,
327
+ *,
328
+ content: RequestContent | None = None,
329
+ data: RequestData | None = None,
330
+ files: RequestFiles | None = None,
331
+ json: Any | None = None,
332
+ params: QueryParamTypes | None = None,
333
+ headers: HeaderTypes | None = None,
334
+ cookies: CookieTypes | None = None,
335
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
336
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
337
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
338
+ extensions: RequestExtensions | None = None,
339
+ model_handler: ModelHandler | None = None,
340
+ response_model: ResponseModelType | None = None,
341
+ response_handlers: ResponseHandlerMap | None = None,
342
+ retry_enabled: bool | None = None,
343
+ retry_profile: RetryProfiles | None = None,
344
+ retry_attempts: int | None = None,
345
+ retry_backoff: float | None = None,
346
+ ) -> ParsedResponse:
347
+ return await self._request(
348
+ self._client.post,
349
+ url=url,
350
+ params=params,
351
+ headers=headers,
352
+ cookies=cookies,
353
+ auth=auth,
354
+ follow_redirects=follow_redirects,
355
+ timeout=timeout,
356
+ extensions=extensions,
357
+ content=content,
358
+ data=data,
359
+ files=files,
360
+ json=json,
361
+ model_handler=model_handler,
362
+ response_model=response_model,
363
+ response_handlers=response_handlers,
364
+ retry_enabled=retry_enabled,
365
+ retry_profile=retry_profile,
366
+ retry_attempts=retry_attempts,
367
+ retry_backoff=retry_backoff,
368
+ )
369
+
370
+ async def put(
371
+ self,
372
+ url: URLTypes,
373
+ *,
374
+ content: RequestContent | None = None,
375
+ data: RequestData | None = None,
376
+ files: RequestFiles | None = None,
377
+ json: Any | None = None,
378
+ params: QueryParamTypes | None = None,
379
+ headers: HeaderTypes | None = None,
380
+ cookies: CookieTypes | None = None,
381
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
382
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
383
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
384
+ extensions: RequestExtensions | None = None,
385
+ model_handler: ModelHandler | None = None,
386
+ response_model: ResponseModelType | None = None,
387
+ response_handlers: ResponseHandlerMap | None = None,
388
+ retry_enabled: bool | None = None,
389
+ retry_profile: RetryProfiles | None = None,
390
+ retry_attempts: int | None = None,
391
+ retry_backoff: float | None = None,
392
+ ) -> ParsedResponse:
393
+ return await self._request(
394
+ self._client.put,
395
+ url=url,
396
+ content=content,
397
+ data=data,
398
+ files=files,
399
+ json=json,
400
+ params=params,
401
+ headers=headers,
402
+ cookies=cookies,
403
+ auth=auth,
404
+ follow_redirects=follow_redirects,
405
+ timeout=timeout,
406
+ extensions=extensions,
407
+ model_handler=model_handler,
408
+ response_model=response_model,
409
+ response_handlers=response_handlers,
410
+ retry_enabled=retry_enabled,
411
+ retry_profile=retry_profile,
412
+ retry_attempts=retry_attempts,
413
+ retry_backoff=retry_backoff,
414
+ )
415
+
416
+ async def patch(
417
+ self,
418
+ url: URLTypes,
419
+ *,
420
+ content: RequestContent | None = None,
421
+ data: RequestData | None = None,
422
+ files: RequestFiles | None = None,
423
+ json: Any | None = None,
424
+ params: QueryParamTypes | None = None,
425
+ headers: HeaderTypes | None = None,
426
+ cookies: CookieTypes | None = None,
427
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
428
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
429
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
430
+ extensions: RequestExtensions | None = None,
431
+ model_handler: ModelHandler | None = None,
432
+ response_model: ResponseModelType | None = None,
433
+ response_handlers: ResponseHandlerMap | None = None,
434
+ retry_enabled: bool | None = None,
435
+ retry_profile: RetryProfiles | None = None,
436
+ retry_attempts: int | None = None,
437
+ retry_backoff: float | None = None,
438
+ ) -> ParsedResponse:
439
+ return await self._request(
440
+ self._client.patch,
441
+ url=url,
442
+ params=params,
443
+ headers=headers,
444
+ cookies=cookies,
445
+ auth=auth,
446
+ follow_redirects=follow_redirects,
447
+ timeout=timeout,
448
+ extensions=extensions,
449
+ content=content,
450
+ data=data,
451
+ files=files,
452
+ json=json,
453
+ model_handler=model_handler,
454
+ response_model=response_model,
455
+ response_handlers=response_handlers,
456
+ retry_enabled=retry_enabled,
457
+ retry_profile=retry_profile,
458
+ retry_attempts=retry_attempts,
459
+ retry_backoff=retry_backoff,
460
+ )
461
+
462
+ async def options(
463
+ self,
464
+ url: URLTypes,
465
+ *,
466
+ params: QueryParamTypes | None = None,
467
+ headers: HeaderTypes | None = None,
468
+ cookies: CookieTypes | None = None,
469
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
470
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
471
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
472
+ extensions: RequestExtensions | None = None,
473
+ model_handler: ModelHandler | None = None,
474
+ response_model: ResponseModelType | None = None,
475
+ response_handlers: ResponseHandlerMap | None = None,
476
+ retry_enabled: bool | None = None,
477
+ retry_profile: RetryProfiles | None = None,
478
+ retry_attempts: int | None = None,
479
+ retry_backoff: float | None = None,
480
+ ) -> ParsedResponse:
481
+ return await self._request(
482
+ self._client.options,
483
+ url=url,
484
+ params=params,
485
+ headers=headers,
486
+ cookies=cookies,
487
+ auth=auth,
488
+ follow_redirects=follow_redirects,
489
+ timeout=timeout,
490
+ extensions=extensions,
491
+ model_handler=model_handler,
492
+ response_model=response_model,
493
+ response_handlers=response_handlers,
494
+ retry_enabled=retry_enabled,
495
+ retry_profile=retry_profile,
496
+ retry_attempts=retry_attempts,
497
+ retry_backoff=retry_backoff,
498
+ )
499
+
500
+ # Helpers
501
+ @property
502
+ def _headers(self) -> dict:
503
+ if self._ignore_incoming_headers:
504
+ return {}
505
+ headers = get_headers()
506
+ return self._filter_headers(dict(headers))
507
+
508
+ def _filter_headers(self, headers: dict) -> dict:
509
+ return {k: v for k, v in headers.items() if k.lower() not in self._filter_headers_list}
510
+
511
+ def _parse_retry_profile(self, **kwargs) -> dict:
512
+ retry_profile = kwargs.pop("retry_profile", self._retry_profile)
513
+ profile_config = deepcopy(RETRY_PROFILES.get(retry_profile, {})) if retry_profile else {}
514
+
515
+ return {
516
+ "retry_enabled": kwargs.pop("retry_enabled", None)
517
+ or profile_config.get("retry_enabled")
518
+ or self._retry_enabled,
519
+ "retry_attempts": kwargs.pop("retry_attempts", None)
520
+ or profile_config.get("retry_attempts")
521
+ or self._retry_attempts,
522
+ "retry_backoff": kwargs.pop("retry_backoff", None)
523
+ or profile_config.get("retry_backoff")
524
+ or self._retry_backoff,
525
+ }
526
+
527
+ @staticmethod
528
+ def _filter_method_kwargs(method: Callable, **kwargs) -> dict:
529
+ valid_params = set(signature(method).parameters)
530
+ return {k: v for k, v in kwargs.items() if v is not None and k in valid_params}
531
+
532
+ @staticmethod
533
+ def _normalize_headers(headers: dict) -> dict:
534
+ result = {}
535
+ for k, v in headers.items():
536
+ lower = k.lower()
537
+ if lower not in result:
538
+ result[lower] = v
539
+ return result
540
+
541
+ @staticmethod
542
+ def _parse_status_code(
543
+ response: Response,
544
+ *,
545
+ response_handlers: dict[int, Callable[[Response], Response | None]] | None = None,
546
+ ) -> Response:
547
+ # Prefer handler override if explicitly provided
548
+ if response_handlers and response.status_code in response_handlers:
549
+ result = response_handlers[response.status_code](response)
550
+ return result if result is not None else response
551
+
552
+ # Fast-path success
553
+ if 200 <= response.status_code <= 299:
554
+ return response
555
+
556
+ try:
557
+ detail = response.json()
558
+ except Exception:
559
+ detail = None
560
+
561
+ if isinstance(detail, dict):
562
+ message = detail.get("detail", f"Unexpected status code: {response.status_code}")
563
+ else:
564
+ message = str(detail) if detail else f"Unexpected status code: {response.status_code}"
565
+
566
+ raise HTTPException(status_code=response.status_code, detail=message)
@@ -0,0 +1,14 @@
1
+ """HTTP response-specific types that depend on httpx."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from httpx import Response
7
+
8
+ from csrd.models.model_parser._types import ResponseModelType
9
+
10
+ ResponseHandler = Callable[[Response], Response | None]
11
+ ResponseHandlerMap = dict[int, ResponseHandler]
12
+ ModelHandler = Callable[[Response, ResponseModelType | None], Any]
13
+
14
+ __all__ = ("ModelHandler", "ResponseHandler", "ResponseHandlerMap")
@@ -0,0 +1,17 @@
1
+ from typing import Literal, TypedDict
2
+
3
+ RetryProfiles = Literal["no_retry", "conservative", "aggressive", "resilient"] | None
4
+
5
+
6
+ class RetryProfile(TypedDict, total=False):
7
+ retry_enabled: bool
8
+ retry_attempts: int | None
9
+ retry_backoff: float | None
10
+
11
+
12
+ RETRY_PROFILES: dict[RetryProfiles, RetryProfile] = {
13
+ "no_retry": {"retry_enabled": False},
14
+ "conservative": {"retry_enabled": True, "retry_attempts": 2, "retry_backoff": 0.5},
15
+ "aggressive": {"retry_enabled": True, "retry_attempts": 5, "retry_backoff": 0.3},
16
+ "resilient": {"retry_enabled": True, "retry_attempts": 7, "retry_backoff": 0.6},
17
+ }
File without changes
@@ -0,0 +1,103 @@
1
+ """Tests for csrd.delegate retry profiles and BaseDelegate helpers."""
2
+
3
+ from csrd.delegate._base_delegate import BaseDelegate
4
+ from csrd.delegate._retry import RETRY_PROFILES
5
+
6
+
7
+ class TestRetryProfiles:
8
+ def test_no_retry_profile(self):
9
+ profile = RETRY_PROFILES["no_retry"]
10
+ assert profile["retry_enabled"] is False
11
+
12
+ def test_conservative_profile(self):
13
+ profile = RETRY_PROFILES["conservative"]
14
+ assert profile["retry_enabled"] is True
15
+ assert profile["retry_attempts"] == 2
16
+ assert profile["retry_backoff"] == 0.5
17
+
18
+ def test_aggressive_profile(self):
19
+ profile = RETRY_PROFILES["aggressive"]
20
+ assert profile["retry_enabled"] is True
21
+ assert profile["retry_attempts"] == 5
22
+
23
+ def test_resilient_profile(self):
24
+ profile = RETRY_PROFILES["resilient"]
25
+ assert profile["retry_enabled"] is True
26
+ assert profile["retry_attempts"] == 7
27
+
28
+
29
+ class TestBaseDelegateHelpers:
30
+ def test_normalize_headers(self):
31
+ headers = {
32
+ "Content-Type": "application/json",
33
+ "X-Custom": "value",
34
+ "content-type": "text/html",
35
+ }
36
+ result = BaseDelegate._normalize_headers(headers)
37
+ # First occurrence wins
38
+ assert result["content-type"] == "application/json"
39
+ assert result["x-custom"] == "value"
40
+
41
+ def test_filter_headers(self):
42
+ delegate = BaseDelegate("http://localhost", header_filter_list=["host", "x-secret"])
43
+ headers = {"host": "localhost", "x-secret": "hidden", "x-public": "visible"}
44
+ result = delegate._filter_headers(headers)
45
+ assert "host" not in result
46
+ assert "x-secret" not in result
47
+ assert result["x-public"] == "visible"
48
+
49
+ def test_filter_headers_default_removes_host(self):
50
+ delegate = BaseDelegate("http://localhost")
51
+ headers = {"host": "example.com", "accept": "application/json"}
52
+ result = delegate._filter_headers(headers)
53
+ assert "host" not in result
54
+ assert result["accept"] == "application/json"
55
+
56
+ def test_ignore_incoming_headers(self):
57
+ delegate = BaseDelegate("http://localhost", ignore_incoming_headers=True)
58
+ assert delegate._headers == {}
59
+
60
+ def test_filter_method_kwargs(self):
61
+ def sample_method(url, headers=None, timeout=None):
62
+ pass
63
+
64
+ result = BaseDelegate._filter_method_kwargs(
65
+ sample_method,
66
+ url="/test",
67
+ headers={"x": "y"},
68
+ timeout=10,
69
+ unknown_param="ignored",
70
+ none_param=None,
71
+ )
72
+ assert "url" in result
73
+ assert "headers" in result
74
+ assert "timeout" in result
75
+ assert "unknown_param" not in result
76
+ assert "none_param" not in result
77
+
78
+ def test_retry_profile_configuration(self):
79
+ delegate = BaseDelegate("http://localhost", retry_profile="conservative")
80
+ assert delegate._retry_enabled is True
81
+ assert delegate._retry_profile == "conservative"
82
+
83
+ def test_no_retry_by_default(self):
84
+ delegate = BaseDelegate("http://localhost")
85
+ assert delegate._retry_enabled is False
86
+
87
+ def test_parse_status_code_success(self):
88
+ from unittest.mock import MagicMock
89
+
90
+ response = MagicMock()
91
+ response.status_code = 200
92
+ result = BaseDelegate._parse_status_code(response)
93
+ assert result is response
94
+
95
+ def test_parse_status_code_with_handler(self):
96
+ from unittest.mock import MagicMock
97
+
98
+ response = MagicMock()
99
+ response.status_code = 404
100
+ custom_response = MagicMock()
101
+ handlers = {404: lambda r: custom_response}
102
+ result = BaseDelegate._parse_status_code(response, response_handlers=handlers)
103
+ assert result is custom_response