actvalue.azure-app-config 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- actvalue_azure_app_config-0.3.0/.gitignore +53 -0
- actvalue_azure_app_config-0.3.0/PKG-INFO +483 -0
- actvalue_azure_app_config-0.3.0/README.md +460 -0
- actvalue_azure_app_config-0.3.0/pyproject.toml +84 -0
- actvalue_azure_app_config-0.3.0/src/azure_app_config/__init__.py +51 -0
- actvalue_azure_app_config-0.3.0/src/azure_app_config/_core.py +915 -0
- actvalue_azure_app_config-0.3.0/src/azure_app_config/_diagnostics.py +587 -0
- actvalue_azure_app_config-0.3.0/src/azure_app_config/_errors.py +116 -0
- actvalue_azure_app_config-0.3.0/src/azure_app_config/_types.py +138 -0
- actvalue_azure_app_config-0.3.0/src/azure_app_config/py.typed +0 -0
- actvalue_azure_app_config-0.3.0/tests/conftest.py +90 -0
- actvalue_azure_app_config-0.3.0/tests/helpers.py +519 -0
- actvalue_azure_app_config-0.3.0/tests/test_client_contract.py +148 -0
- actvalue_azure_app_config-0.3.0/tests/test_concurrency.py +263 -0
- actvalue_azure_app_config-0.3.0/tests/test_default_credential.py +107 -0
- actvalue_azure_app_config-0.3.0/tests/test_error_cause.py +409 -0
- actvalue_azure_app_config-0.3.0/tests/test_failure_logging.py +415 -0
- actvalue_azure_app_config-0.3.0/tests/test_hydration.py +452 -0
- actvalue_azure_app_config-0.3.0/tests/test_memoisation.py +346 -0
- actvalue_azure_app_config-0.3.0/tests/test_provider_contract_integration.py +226 -0
- actvalue_azure_app_config-0.3.0/tests/test_selectors_and_input.py +336 -0
- actvalue_azure_app_config-0.3.0/tests/test_status_and_retry_after.py +309 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Compiled code
|
|
2
|
+
dist/
|
|
3
|
+
lib/
|
|
4
|
+
types/
|
|
5
|
+
*.tsbuildinfo
|
|
6
|
+
|
|
7
|
+
# Packed tarballs
|
|
8
|
+
*.tgz
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
*$py.class
|
|
14
|
+
*.so
|
|
15
|
+
.Python
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
ENV/
|
|
19
|
+
env/
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
.mypy_cache/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
build/
|
|
25
|
+
develop-eggs/
|
|
26
|
+
downloads/
|
|
27
|
+
eggs/
|
|
28
|
+
.eggs/
|
|
29
|
+
sdist/
|
|
30
|
+
wheels/
|
|
31
|
+
*.egg
|
|
32
|
+
.coverage
|
|
33
|
+
htmlcov/
|
|
34
|
+
|
|
35
|
+
# Node
|
|
36
|
+
node_modules/
|
|
37
|
+
coverage/
|
|
38
|
+
.nyc_output/
|
|
39
|
+
npm-debug.log*
|
|
40
|
+
yarn-debug.log*
|
|
41
|
+
yarn-error.log*
|
|
42
|
+
.pnpm-debug.log*
|
|
43
|
+
|
|
44
|
+
# Local environment — never committed, this repository is public
|
|
45
|
+
.env
|
|
46
|
+
.env.*
|
|
47
|
+
!.env.example
|
|
48
|
+
local.settings.json
|
|
49
|
+
|
|
50
|
+
# Editors and OS
|
|
51
|
+
.DS_Store
|
|
52
|
+
.idea/
|
|
53
|
+
*.swp
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: actvalue.azure-app-config
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Hydrate os.environ from Azure App Configuration, with the failure handling that platform actually needs
|
|
5
|
+
Project-URL: Homepage, https://github.com/pmosconi/azure-app-config
|
|
6
|
+
Project-URL: Repository, https://github.com/pmosconi/azure-app-config
|
|
7
|
+
Author: ActValue
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: app-configuration,azure,configuration,environment,key-vault
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: azure-appconfiguration-provider<3,>=2.5.0
|
|
21
|
+
Requires-Dist: azure-identity<2,>=1.25.3
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# actvalue.azure-app-config
|
|
25
|
+
|
|
26
|
+
Hydrate `os.environ` from **Azure App Configuration**, with the failure handling that platform
|
|
27
|
+
actually needs. The Python half of [`@actvalue/azure-app-config`](https://github.com/pmosconi/azure-app-config/blob/main/README.md): the same option
|
|
28
|
+
names in snake_case, the same defaults, the same error semantics and the same four invariants.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install actvalue.azure-app-config
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from azure_app_config import HydrateOptions, hydrate
|
|
36
|
+
|
|
37
|
+
CONFIG = HydrateOptions(
|
|
38
|
+
keys={
|
|
39
|
+
"shared:mongoUrl": "MONGO_URL",
|
|
40
|
+
"shared:serviceBus": "SERVICE_BUS_CONNECTION",
|
|
41
|
+
"myapp:httpPort": "HTTP_PORT",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
hydrate(CONFIG)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> **Status: `0.3.0`, pre-1.0.** It matches the TypeScript `0.3.0` release except for `gated()`,
|
|
49
|
+
> which is not here yet (see [Known gaps](#known-gaps)). `1.0.0` follows an API review across both
|
|
50
|
+
> halves. Python 3.11 or later. See the [root README](https://github.com/pmosconi/azure-app-config/blob/main/README.md) for why each behaviour exists
|
|
51
|
+
> and [`CHANGELOG.md`](https://github.com/pmosconi/azure-app-config/blob/main/CHANGELOG.md) for what changed.
|
|
52
|
+
|
|
53
|
+
Everything else comes from the environment by default:
|
|
54
|
+
|
|
55
|
+
| Variable | Meaning | Required |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `APP_CONFIG_ENDPOINT` | Store endpoint, read with `DefaultAzureCredential` | yes, unless a connection string is set |
|
|
58
|
+
| `APP_CONFIG_LABEL` | The one label to read (`prod`, `staging`, …) | yes |
|
|
59
|
+
| `APP_CONFIG_CONNECTION_STRING` | Access-key fallback for runs with no identity to borrow | no |
|
|
60
|
+
| `WEBSITE_INSTANCE_ID` | Injected by App Service and Azure Functions. Its absence means a developer machine, where precedence inverts. Verified on App Service and Functions Premium/Elastic; **unverified on Flex Consumption and Linux Consumption**, see [Precedence](#precedence) | set by the platform |
|
|
61
|
+
|
|
62
|
+
## What it does, in one paragraph
|
|
63
|
+
|
|
64
|
+
`hydrate()` makes **one attempt**: it loads exactly the keys in the map, at exactly one label,
|
|
65
|
+
resolves any Key Vault references with your identity, and writes the values into `os.environ`,
|
|
66
|
+
all or nothing. A success is memoised. A failure is not, but for `retry_floor_ms` (30 s) after
|
|
67
|
+
one, further calls raise `ConfigFloorError` without touching the store, so a stream of messages
|
|
68
|
+
cannot spend a capped store's daily quota. A failure says why: `ConfigLoadError.detail` is what
|
|
69
|
+
the store, the network or the credential actually did. Read [the store's quota](https://github.com/pmosconi/azure-app-config/blob/main/README.md#the-stores-quota)
|
|
70
|
+
before choosing a tier.
|
|
71
|
+
|
|
72
|
+
## Long-lived processes
|
|
73
|
+
|
|
74
|
+
Bind the port first, answer unhealthy, then hydrate with backoff:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from azure_app_config import hydrate_with_backoff
|
|
78
|
+
|
|
79
|
+
ready = False
|
|
80
|
+
start_healthcheck(lambda: ready) # listening before anything can fail
|
|
81
|
+
|
|
82
|
+
hydrate_with_backoff(CONFIG) # 5 s → 10 s → … → 600 s, until it succeeds
|
|
83
|
+
ready = True
|
|
84
|
+
main()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It sleeps, and returns only once the store answers, so it belongs in a process's startup and
|
|
88
|
+
never inside a function invocation. It re-raises every `ConfigInputError` at once — a typo is
|
|
89
|
+
not an outage. There is no async variant: an asyncio process that wants one runs
|
|
90
|
+
`await asyncio.to_thread(hydrate_with_backoff, CONFIG)`, knowing that cancelling the task does not
|
|
91
|
+
stop the thread.
|
|
92
|
+
|
|
93
|
+
## Azure Functions
|
|
94
|
+
|
|
95
|
+
The host builds a trigger's connection before your code runs, so trigger connections stay app
|
|
96
|
+
settings. Everything else is hydrated on first use, at the top of each function. Build one
|
|
97
|
+
options object in a module with no side effects and pass it to every call:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
# config.py
|
|
101
|
+
import logging
|
|
102
|
+
from types import SimpleNamespace
|
|
103
|
+
|
|
104
|
+
from azure_app_config import HydrateOptions
|
|
105
|
+
|
|
106
|
+
log = logging.getLogger("myapp")
|
|
107
|
+
|
|
108
|
+
CONFIG = HydrateOptions(
|
|
109
|
+
keys=KEYS,
|
|
110
|
+
# The success line at warning, so a host that keeps only warnings still shows it; a failed
|
|
111
|
+
# attempt through error, logged once by hydrate() itself.
|
|
112
|
+
logger=SimpleNamespace(info=log.warning, error=log.error),
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Module-level clients have to become lazy: a client built at import reads the environment before
|
|
117
|
+
hydration can fill it.
|
|
118
|
+
|
|
119
|
+
**The logger is per attempt, not per call.** The call that starts an attempt logs it — the
|
|
120
|
+
success line, or one failure line; calls that join it, and memo hits, log nothing. That is why
|
|
121
|
+
every call passes `CONFIG`. The default logger is `logging.getLogger("azure_app_config")`, outside
|
|
122
|
+
the `azure` logger tree on purpose: consumers commonly silence `azure` below WARNING.
|
|
123
|
+
|
|
124
|
+
A logging handler may call `hydrate()`: the lines are written once the attempt is settled, so it
|
|
125
|
+
gets the memoised result or a `ConfigFloorError`. The default credential is created before an
|
|
126
|
+
attempt starts and outside the package's lock, so a handler on `azure.identity`, which its
|
|
127
|
+
constructor logs to on the calling thread, may call in too; a `hydrate()` from there raises
|
|
128
|
+
`ConfigLoadError` ("creating the default credential"), unlogged, and nothing is attempted for it.
|
|
129
|
+
Not from a handler on the provider's and SDK clients' loggers (`azure.appconfiguration.provider`
|
|
130
|
+
and the like), though: those run on the load thread, and such a call waits for the attempt it is
|
|
131
|
+
part of until `timeout_ms` fires.
|
|
132
|
+
|
|
133
|
+
### On a message trigger, wait for the floor
|
|
134
|
+
|
|
135
|
+
After a failed attempt, every call for `retry_floor_ms` raises `ConfigFloorError` and sends
|
|
136
|
+
nothing. A handler that re-raises abandons the message, Service Bus redelivers it at once, and
|
|
137
|
+
every redelivery fails the same way within milliseconds: `maxDeliveryCount` is spent in seconds
|
|
138
|
+
and the message is dead-lettered. Wait as long as `retry_after_ms(error)` says, make one more
|
|
139
|
+
attempt, and only then let the invocation fail:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import time
|
|
143
|
+
|
|
144
|
+
import azure.functions as func
|
|
145
|
+
|
|
146
|
+
from azure_app_config import ConfigInputError, hydrate, retry_after_ms
|
|
147
|
+
from config import CONFIG
|
|
148
|
+
|
|
149
|
+
app = func.FunctionApp()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def configured() -> None:
|
|
153
|
+
try:
|
|
154
|
+
hydrate(CONFIG)
|
|
155
|
+
except ConfigInputError:
|
|
156
|
+
raise # waiting won't fix it
|
|
157
|
+
except Exception as error:
|
|
158
|
+
wait = retry_after_ms(error) or 0 # None here: the floor is open, go now
|
|
159
|
+
time.sleep(wait / 1000)
|
|
160
|
+
hydrate(CONFIG) # one more attempt; if it fails, the message is retried
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.service_bus_queue_trigger(arg_name="message", queue_name="rollup", connection="SERVICE_BUS_CONNECTION")
|
|
164
|
+
def rollup(message: func.ServiceBusMessage) -> None:
|
|
165
|
+
configured()
|
|
166
|
+
process(message)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`retry_after_ms(error)` covers both: a `ConfigFloorError`'s own `retry_after_ms`, and for a fresh
|
|
170
|
+
failure the time until the floor it armed opens — rounded up, plus a 50 ms margin, so a timer
|
|
171
|
+
that fires a little early still lands outside the floor. Its `None` means two things — don't
|
|
172
|
+
retry, for a `ConfigInputError`; retry now, for anything else — so branch on
|
|
173
|
+
`isinstance(error, ConfigInputError)`, never on `None`. Nothing to log: `hydrate()` logged the
|
|
174
|
+
failed attempt, and logs nothing for a floor rejection. The pattern assumes `retry_floor_ms` sits
|
|
175
|
+
well inside the invocation's timeout, as the 30 s default does.
|
|
176
|
+
|
|
177
|
+
An **async handler** does the same through `hydrate_async`, which runs `hydrate` on a worker
|
|
178
|
+
thread and shares its memo, floor and in-flight attempt:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
import asyncio
|
|
182
|
+
|
|
183
|
+
from azure_app_config import ConfigInputError, hydrate_async, retry_after_ms
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def configured_async() -> None:
|
|
187
|
+
try:
|
|
188
|
+
await hydrate_async(CONFIG)
|
|
189
|
+
except ConfigInputError:
|
|
190
|
+
raise
|
|
191
|
+
except Exception as error:
|
|
192
|
+
await asyncio.sleep((retry_after_ms(error) or 0) / 1000)
|
|
193
|
+
await hydrate_async(CONFIG)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Callers that arrive while an attempt is in flight — from any thread, sync or async — join it: one
|
|
197
|
+
request, and on failure every one of them receives the identical exception object.
|
|
198
|
+
|
|
199
|
+
### Timers
|
|
200
|
+
|
|
201
|
+
Call `configured()` (or `hydrate(CONFIG)`) at the top of the function. A failed run fails; the
|
|
202
|
+
next schedule tries again, and the 30 s floor is far shorter than a typical schedule.
|
|
203
|
+
|
|
204
|
+
### Starting early at import
|
|
205
|
+
|
|
206
|
+
A `hydrate(CONFIG)` at module top is an early start, never the guarantee — handlers still call it.
|
|
207
|
+
Never let it raise at import, which fails the worker's indexing:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
try:
|
|
211
|
+
hydrate(CONFIG)
|
|
212
|
+
except Exception:
|
|
213
|
+
pass # hydrate() logged it; handlers will call again
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Import blocks for up to `timeout_ms` while it runs.
|
|
217
|
+
|
|
218
|
+
### Health without spending quota
|
|
219
|
+
|
|
220
|
+
A health endpoint that calls `hydrate()` starts a store attempt whenever the floor opens, and
|
|
221
|
+
pings from several instances spend a capped store's quota during an outage. `hydration_status()`
|
|
222
|
+
reads what `hydrate()` has done and never makes a request:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
import json
|
|
226
|
+
|
|
227
|
+
from azure_app_config import hydration_status
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.route(route="health", auth_level=func.AuthLevel.ANONYMOUS)
|
|
231
|
+
def health(req: func.HttpRequest) -> func.HttpResponse:
|
|
232
|
+
config = hydration_status(KEYS)
|
|
233
|
+
return func.HttpResponse(
|
|
234
|
+
json.dumps({"config": config.state, "loadedAt": config.loaded_at, "nextAttemptAt": config.next_attempt_at}),
|
|
235
|
+
status_code=503 if config.state == "failing" else 200,
|
|
236
|
+
mimetype="application/json",
|
|
237
|
+
)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`none` and `pending` are normal: nothing is loaded until a handler asks.
|
|
241
|
+
|
|
242
|
+
## Precedence
|
|
243
|
+
|
|
244
|
+
Deployed, **the store wins**. On a developer machine a value already in the environment wins, so
|
|
245
|
+
a `.env` line can point one variable somewhere local. `local_overrides_win` defaults to "not
|
|
246
|
+
deployed", read on every attempt from `WEBSITE_INSTANCE_ID`, which App Service and Functions
|
|
247
|
+
inject and `func start`, `python` and pytest never set. **Any other host — Container Apps,
|
|
248
|
+
Kubernetes, a VM — must pass `local_overrides_win=False`.** Local precedence still reads the
|
|
249
|
+
store: the request also checks that the store exists and that the identity holds its grants.
|
|
250
|
+
|
|
251
|
+
The signal is verified on App Service and on Functions Premium/Elastic Premium. **On Flex
|
|
252
|
+
Consumption and Linux Consumption it is unverified** — the host may leave it empty, which reads as
|
|
253
|
+
a developer machine — so pass `local_overrides_win=False` explicitly there until it is confirmed.
|
|
254
|
+
The success line's mode is how you confirm it: `(store wins: WEBSITE_INSTANCE_ID present)` from a
|
|
255
|
+
deployed instance means the signal is there.
|
|
256
|
+
|
|
257
|
+
The success line says which side won and why:
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
Configuration loaded from App Configuration, label prod: MONGO_URL, HTTP_PORT (store wins: WEBSITE_INSTANCE_ID present)
|
|
261
|
+
Configuration loaded from App Configuration, label prod: MONGO_URL, HTTP_PORT (local wins: WEBSITE_INSTANCE_ID absent)
|
|
262
|
+
Configuration loaded from App Configuration, label prod: MONGO_URL, HTTP_PORT (store wins: local_overrides_win option false)
|
|
263
|
+
Configuration loaded from App Configuration, label prod: MONGO_URL, HTTP_PORT (local wins: local_overrides_win option true)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The option is read by truthiness, so the string `"false"` gives `local wins: … option true`;
|
|
267
|
+
`None` is not passed. An empty `WEBSITE_INSTANCE_ID` is absent. Names only, never values; when
|
|
268
|
+
something was kept, a second line, `Kept from the local environment: …`, names it.
|
|
269
|
+
|
|
270
|
+
## API
|
|
271
|
+
|
|
272
|
+
### `hydrate(options: HydrateOptions) -> HydrationResult`
|
|
273
|
+
|
|
274
|
+
One attempt, memoised on success against the key map and label. Raises `ConfigLoadError` when
|
|
275
|
+
the store cannot be read, `ConfigInputError` for a call no retry can fix, `ConfigFloorError`
|
|
276
|
+
inside the retry floor, and a `LookupError` naming every key that was absent, empty or not a
|
|
277
|
+
string at that label. All or nothing: a raise leaves `os.environ` as it was.
|
|
278
|
+
|
|
279
|
+
Every failure that reached the store arms the floor — a refused or failed read, a missing key, an
|
|
280
|
+
input error raised after the store answered. Input refused before any request leaves it alone.
|
|
281
|
+
|
|
282
|
+
A failed attempt is logged once, through the `error` method of the starting call's logger
|
|
283
|
+
(`info` if it has none):
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
Configuration load failed: <the error's message>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Joiners, memo hits and floor rejections log nothing. A call refused before any request is logged
|
|
290
|
+
the first time its message is seen, and not again until `reset_hydration()`. A logger that raises
|
|
291
|
+
changes nothing; a coroutine a logger returns is closed unawaited.
|
|
292
|
+
|
|
293
|
+
| `HydrateOptions` field | Default | |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `keys` | — | **Required.** `{store_key: variable_name}`. No unescaped `*` or `,`; `\*` and `\,` match the literal character |
|
|
296
|
+
| `label` | `APP_CONFIG_LABEL` | Refused if neither is set, or if it holds `*` or `,` |
|
|
297
|
+
| `endpoint` | `APP_CONFIG_ENDPOINT` | |
|
|
298
|
+
| `connection_string` | `APP_CONFIG_CONNECTION_STRING` | Takes precedence when set. Never in the `repr` |
|
|
299
|
+
| `credential` | one `DefaultAzureCredential()` per process | Used for the store and for Key Vault references. The default one is created on first need, reused by every attempt (its token cache survives a failure), and dropped — not closed — by `reset_hydration()`; yours is never touched |
|
|
300
|
+
| `timeout_ms` | `15_000` | Bound on one attempt. Finite, above 0, at most 2**31 - 1 |
|
|
301
|
+
| `retry_floor_ms` | `DEFAULT_RETRY_FLOOR_MS` (`30_000`) | Finite, 0 or more — NaN would switch it off |
|
|
302
|
+
| `local_overrides_win` | `not WEBSITE_INSTANCE_ID`, read every attempt | Dev-only. Other hosts pass `False` |
|
|
303
|
+
| `logger` | `logging.getLogger("azure_app_config")` | `info` gets the success line, `error` (else `info`) a failure |
|
|
304
|
+
|
|
305
|
+
`HydrationResult` is `label`, `applied`, `kept` (tuples of variable names) and `loaded_at`
|
|
306
|
+
(milliseconds since the epoch).
|
|
307
|
+
|
|
308
|
+
**The bound.** `hydrate()` returns within `timeout_ms`. The provider checks its own startup
|
|
309
|
+
timeout only between passes, so a request that hangs would hold it far longer; the load therefore
|
|
310
|
+
runs on a daemon thread, and when the bound fires the call raises and the thread is abandoned —
|
|
311
|
+
anything it later returns is closed, and it never writes the environment, which only the calling
|
|
312
|
+
thread does. The provider holds a failure until five seconds after it started; below 5 000 ms such
|
|
313
|
+
a failure arrives after the bound and is reported from the wire evidence instead.
|
|
314
|
+
|
|
315
|
+
**What ends an abandoned thread, and what does not.** Every store client and every Key Vault
|
|
316
|
+
client the provider builds gets `retry_total=0` and connection and read timeouts of twice
|
|
317
|
+
`timeout_ms` (call it 2T):
|
|
318
|
+
|
|
319
|
+
- No SDK retries, and so no `Retry-After` sleep — azure-core sleeps whatever the header says,
|
|
320
|
+
uncapped, before it re-checks its own timeout, so the only way to bound it is not to retry. The
|
|
321
|
+
caller's floor and backoff do the retrying; the price is below.
|
|
322
|
+
- Each connection attempt is bounded at 2T, and each socket read at 2T of silence.
|
|
323
|
+
- The provider's own loop sends nothing after a failed pass (it backs its only client off for
|
|
324
|
+
30 s) and raises once its next 5 s delay would overrun `timeout_ms`.
|
|
325
|
+
|
|
326
|
+
Requests inside one pass run one after another: one list request per selector (more if the
|
|
327
|
+
result is paged), then, per Key Vault reference, up to two vault requests (the challenge, then the
|
|
328
|
+
read). So the worst case, if every request answers just inside its limits, is about
|
|
329
|
+
(selectors + pages + 2 × references) × 4T after the load started. Not bounded by this package:
|
|
330
|
+
the credential's own token requests, which have their own timeouts and retries; a server that
|
|
331
|
+
trickles bytes more often than every 2T, since the read timeout is per read; the system resolver;
|
|
332
|
+
and the provider's DNS replica discovery for real `*.azconfig.io` endpoints, a few lookups of up
|
|
333
|
+
to about ten seconds each before the first request.
|
|
334
|
+
|
|
335
|
+
### `hydrate_async(options) -> HydrationResult` (coroutine)
|
|
336
|
+
|
|
337
|
+
`hydrate()` on a worker thread via `asyncio.to_thread`: the same memo, floor and in-flight
|
|
338
|
+
attempt. Cancelling the task does not stop an attempt already running.
|
|
339
|
+
|
|
340
|
+
### `hydrate_with_backoff(options, backoff: BackoffOptions | None = None) -> HydrationResult`
|
|
341
|
+
|
|
342
|
+
Calls `hydrate` until it succeeds. `BackoffOptions(initial_ms=5_000, max_ms=600_000,
|
|
343
|
+
on_error=None)`. Re-raises every `ConfigInputError`; sleeps through a `ConfigFloorError` without
|
|
344
|
+
calling `on_error`, logging or widening the delay. After a real failure, `on_error(error,
|
|
345
|
+
next_delay_ms)` gets the real wait — the backoff delay or the time until the floor opens, whichever
|
|
346
|
+
is longer. The default `on_error` logs `Configuration load failed, retrying in <s>s: <message>`,
|
|
347
|
+
guarded so a broken logger cannot end the loop; a custom one that raises ends it. The attempts it
|
|
348
|
+
starts do not also log `hydrate()`'s failure line. A wait longer than 2**31 - 1 ms is slept in
|
|
349
|
+
steps.
|
|
350
|
+
|
|
351
|
+
### `retry_after_ms(error) -> int | None`
|
|
352
|
+
|
|
353
|
+
| `error` | Returns |
|
|
354
|
+
|---|---|
|
|
355
|
+
| `ConfigFloorError` | Its `retry_after_ms` |
|
|
356
|
+
| `ConfigInputError`, whether or not it reached the store | `None` — waiting won't fix it |
|
|
357
|
+
| Anything else | Time until the armed floor opens (by the `retry_floor_ms` of the attempt that armed it), rounded up, plus 50 ms; `None` if open — retry now |
|
|
358
|
+
|
|
359
|
+
Makes no request.
|
|
360
|
+
|
|
361
|
+
### `hydration_status(keys, label=None) -> HydrationStatus`
|
|
362
|
+
|
|
363
|
+
`state` is `loaded`, `pending`, `failing` or `none`, with `loaded_at`, `failed_at`, `last_error`
|
|
364
|
+
and `next_attempt_at` (milliseconds since the epoch) as in the TypeScript half. Never makes a
|
|
365
|
+
request and never starts an attempt. Raises `ConfigInputError` for a call `hydrate()` would refuse
|
|
366
|
+
before any request. `next_attempt_at` appears only in `failing` and `none`, while the floor —
|
|
367
|
+
armed by any key map — is closed.
|
|
368
|
+
|
|
369
|
+
### `reset_hydration()`
|
|
370
|
+
|
|
371
|
+
Clears the memo, the recorded failures, the floor and the set of pre-request errors already
|
|
372
|
+
logged, and drops the package's own `DefaultAzureCredential` without closing it. For tests. An
|
|
373
|
+
attempt still in flight, or an abandoned load thread, settles against the state it started in and
|
|
374
|
+
keeps using the credential it started with; the garbage collector takes it afterwards.
|
|
375
|
+
|
|
376
|
+
### Errors
|
|
377
|
+
|
|
378
|
+
The cause is always `__cause__`, the Python idiom — there is no separate `cause` attribute.
|
|
379
|
+
|
|
380
|
+
- **`ConfigLoadError`** — `detail` (the reason), `status_code`, `observations` (every distinct
|
|
381
|
+
failure the store's pipeline saw), `__cause__` (the provider's error, unmodified).
|
|
382
|
+
- **`ConfigInputError`** — a call no retry can fix: this package's own checks, and an argument
|
|
383
|
+
the provider refused **before any network activity** (no token asked for, no request sent).
|
|
384
|
+
Anything that fails later is a `ConfigLoadError`, whatever its class — a transient failure can
|
|
385
|
+
arrive as a `ValueError`, and an input error stops every retry for good. `reached_store` is
|
|
386
|
+
therefore always `False` in this release; it stays for parity with the TypeScript half, where it
|
|
387
|
+
is equally defensive, and `hydrate_with_backoff` stops on any `ConfigInputError`.
|
|
388
|
+
- **`ConfigFloorError`** — `retry_after_ms`; `__cause__` is the error of the attempt that armed
|
|
389
|
+
the floor. Neither of the other two, so a count of load failures does not count it and
|
|
390
|
+
`hydrate_with_backoff` waits it out.
|
|
391
|
+
|
|
392
|
+
`DEFAULT_RETRY_FLOOR_MS` is `30_000`.
|
|
393
|
+
|
|
394
|
+
## What a failure costs, on provider 2.5.0
|
|
395
|
+
|
|
396
|
+
Measured against a local fake store, per attempt at the default 15 s timeout:
|
|
397
|
+
|
|
398
|
+
| Failure | Store requests | Arrives after |
|
|
399
|
+
|---|---|---|
|
|
400
|
+
| Success, or a missing key | one per key | at once |
|
|
401
|
+
| 401 or 403 | 1 (the provider backs its only client off for 30 s) | ~10 s |
|
|
402
|
+
| 429 or 5xx | 1 (no SDK retries, and no `Retry-After` sleep) | ~10 s |
|
|
403
|
+
| Unreachable: failed lookup, refused connection | 0 | ~10 s |
|
|
404
|
+
| A Key Vault reference it cannot parse, or with no URI | one per key, then `ConfigLoadError` naming the reference, with the stored URI withheld | 5 s |
|
|
405
|
+
| A vault that refuses a reference | one per key, then `ConfigLoadError` | ~10 s |
|
|
406
|
+
|
|
407
|
+
A broken Key Vault reference is retried like any other load failure: fixing the reference in the
|
|
408
|
+
store heals the process.
|
|
409
|
+
|
|
410
|
+
**The price of `retry_total=0`, plainly: it is a trade, not a saving.** A single transient 5xx
|
|
411
|
+
or 429 now fails the attempt: at the defaults it arrives after about 10 s (the provider benches its
|
|
412
|
+
only client for 30 s, then waits out its startup timeout) and arms the 30 s floor, so the process
|
|
413
|
+
goes about 40 s without configuration. On the TypeScript half the SDK's own retries would usually
|
|
414
|
+
absorb such a blip inside the attempt. What it buys: one request per failure, and an abandoned
|
|
415
|
+
load thread that ends, since azure-core sleeps a `Retry-After` uncapped. If a blip matters more
|
|
416
|
+
than those, the caller retries — the floor and `hydrate_with_backoff` are built for that.
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
Requests, not quota units: on a capped tier each one costs several. The provider also logs its
|
|
420
|
+
own warning, `Failed to load configurations from endpoint …`, once per failing attempt, through
|
|
421
|
+
the `azure.appconfiguration.provider` logger.
|
|
422
|
+
|
|
423
|
+
## Testing a consumer
|
|
424
|
+
|
|
425
|
+
Replace the provider's `load()` at the module boundary — the package looks it up on
|
|
426
|
+
`azure.appconfiguration.provider` at call time — and reset the package and the variables it
|
|
427
|
+
writes between tests:
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
import os
|
|
431
|
+
|
|
432
|
+
import pytest
|
|
433
|
+
|
|
434
|
+
from azure_app_config import reset_hydration
|
|
435
|
+
|
|
436
|
+
KEYS = {"shared:mongoUrl": "MONGO_URL"}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class FakeConfig:
|
|
440
|
+
def __init__(self, values: dict[str, str]) -> None:
|
|
441
|
+
self.values = values
|
|
442
|
+
|
|
443
|
+
def get(self, key: str) -> str | None:
|
|
444
|
+
return self.values.get(key)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@pytest.fixture(autouse=True)
|
|
448
|
+
def config(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
449
|
+
reset_hydration()
|
|
450
|
+
for variable in KEYS.values():
|
|
451
|
+
monkeypatch.delenv(variable, raising=False)
|
|
452
|
+
monkeypatch.setenv("APP_CONFIG_ENDPOINT", "https://example.invalid")
|
|
453
|
+
monkeypatch.setenv("APP_CONFIG_LABEL", "test")
|
|
454
|
+
values = {"shared:mongoUrl": "mongodb://example.invalid/app"}
|
|
455
|
+
monkeypatch.setattr("azure.appconfiguration.provider.load", lambda *a, **k: FakeConfig(values))
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Clear the mapped variables too.** A test run sets no `WEBSITE_INSTANCE_ID`, so local precedence
|
|
459
|
+
is on, and a value one test's `hydrate()` wrote would beat the next test's fake store. Do not
|
|
460
|
+
`importlib.reload` the package for fresh state: that makes a second set of error classes that
|
|
461
|
+
`except` clauses elsewhere will not match. Call `reset_hydration()`.
|
|
462
|
+
|
|
463
|
+
## Known gaps
|
|
464
|
+
|
|
465
|
+
- **`gated()`**, the TypeScript half's wrapper that answers 503 with `Retry-After` for an HTTP
|
|
466
|
+
handler while configuration is not loaded. No Python consumer has HTTP triggers yet; it will be
|
|
467
|
+
added, additively, with the first one. Until then an HTTP handler does it by hand: on any
|
|
468
|
+
exception from `hydrate`, answer 503 with `Retry-After: max(1, ceil(retry_after_ms(error) / 1000))`
|
|
469
|
+
seconds when `retry_after_ms` gives a wait, and without it otherwise.
|
|
470
|
+
- **No dual-build registry, no error brands.** The TypeScript package ships two builds that one
|
|
471
|
+
process can load, so it keeps its state on `globalThis` and brands its errors. A Python process
|
|
472
|
+
imports a module once: there is one state and one set of classes by construction.
|
|
473
|
+
- **No async `hydrate_with_backoff`.** See [Long-lived processes](#long-lived-processes).
|
|
474
|
+
|
|
475
|
+
## Development
|
|
476
|
+
|
|
477
|
+
From the repository root: `make install-py`, `make test-py`, `make test-integration-py` (the real
|
|
478
|
+
provider against an RFC 2606 `.invalid` endpoint and a loopback fake store — no Azure, no egress),
|
|
479
|
+
`make lint-py`, `make typecheck-py`, `make build-py`.
|
|
480
|
+
|
|
481
|
+
## License
|
|
482
|
+
|
|
483
|
+
MIT
|