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.
- csrd_delegate-0.1.0/.gitignore +217 -0
- csrd_delegate-0.1.0/PKG-INFO +15 -0
- csrd_delegate-0.1.0/README.md +22 -0
- csrd_delegate-0.1.0/pyproject.toml +30 -0
- csrd_delegate-0.1.0/src/csrd/delegate/__init__.py +4 -0
- csrd_delegate-0.1.0/src/csrd/delegate/_base_delegate.py +566 -0
- csrd_delegate-0.1.0/src/csrd/delegate/_response_types.py +14 -0
- csrd_delegate-0.1.0/src/csrd/delegate/_retry.py +17 -0
- csrd_delegate-0.1.0/src/csrd/delegate/py.typed +0 -0
- csrd_delegate-0.1.0/tests/test_delegate.py +103 -0
|
@@ -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,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
|