togul 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.
- togul-0.1.0/.github/workflows/ci.yml +65 -0
- togul-0.1.0/.github/workflows/release.yml +31 -0
- togul-0.1.0/.gitignore +11 -0
- togul-0.1.0/LICENSE +21 -0
- togul-0.1.0/PKG-INFO +318 -0
- togul-0.1.0/README.md +282 -0
- togul-0.1.0/examples/basic.py +16 -0
- togul-0.1.0/examples/django_settings.py +37 -0
- togul-0.1.0/examples/fastapi_app.py +26 -0
- togul-0.1.0/pyproject.toml +86 -0
- togul-0.1.0/src/togul/__init__.py +25 -0
- togul-0.1.0/src/togul/_sse.py +29 -0
- togul-0.1.0/src/togul/_transport.py +77 -0
- togul-0.1.0/src/togul/async_client.py +120 -0
- togul-0.1.0/src/togul/cache.py +53 -0
- togul-0.1.0/src/togul/client.py +125 -0
- togul-0.1.0/src/togul/config.py +32 -0
- togul-0.1.0/src/togul/contrib/__init__.py +3 -0
- togul-0.1.0/src/togul/contrib/django.py +138 -0
- togul-0.1.0/src/togul/contrib/fastapi.py +93 -0
- togul-0.1.0/src/togul/errors.py +22 -0
- togul-0.1.0/src/togul/py.typed +0 -0
- togul-0.1.0/src/togul/result.py +26 -0
- togul-0.1.0/src/togul/stream.py +179 -0
- togul-0.1.0/tests/__init__.py +0 -0
- togul-0.1.0/tests/conftest.py +81 -0
- togul-0.1.0/tests/test_async_client.py +111 -0
- togul-0.1.0/tests/test_cache.py +74 -0
- togul-0.1.0/tests/test_client.py +153 -0
- togul-0.1.0/tests/test_config.py +48 -0
- togul-0.1.0/tests/test_contrib_django.py +190 -0
- togul-0.1.0/tests/test_contrib_fastapi.py +211 -0
- togul-0.1.0/tests/test_public_api.py +35 -0
- togul-0.1.0/tests/test_sse.py +44 -0
- togul-0.1.0/tests/test_stream.py +191 -0
- togul-0.1.0/tests/test_transport.py +139 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
|
|
22
|
+
- name: Install
|
|
23
|
+
run: |
|
|
24
|
+
python -m pip install --upgrade pip
|
|
25
|
+
pip install -e ".[dev,django,fastapi]"
|
|
26
|
+
|
|
27
|
+
- name: Test
|
|
28
|
+
run: python -m pytest -v
|
|
29
|
+
|
|
30
|
+
lint:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
|
|
35
|
+
- uses: actions/setup-python@v5
|
|
36
|
+
with:
|
|
37
|
+
python-version: "3.12"
|
|
38
|
+
|
|
39
|
+
- name: Install
|
|
40
|
+
run: |
|
|
41
|
+
python -m pip install --upgrade pip
|
|
42
|
+
pip install -e ".[dev,django,fastapi]"
|
|
43
|
+
|
|
44
|
+
- name: Ruff
|
|
45
|
+
run: |
|
|
46
|
+
ruff check src tests
|
|
47
|
+
ruff format --check src tests
|
|
48
|
+
|
|
49
|
+
- name: Mypy
|
|
50
|
+
run: mypy
|
|
51
|
+
|
|
52
|
+
package:
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@v4
|
|
56
|
+
|
|
57
|
+
- uses: actions/setup-python@v5
|
|
58
|
+
with:
|
|
59
|
+
python-version: "3.12"
|
|
60
|
+
|
|
61
|
+
- name: Build and check the distribution
|
|
62
|
+
run: |
|
|
63
|
+
python -m pip install --upgrade pip build twine
|
|
64
|
+
python -m build
|
|
65
|
+
twine check dist/*
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
publish:
|
|
7
|
+
description: "Upload to PyPI (leave false for a dry run)"
|
|
8
|
+
type: boolean
|
|
9
|
+
default: false
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Build
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip build twine
|
|
26
|
+
python -m build
|
|
27
|
+
twine check dist/*
|
|
28
|
+
|
|
29
|
+
- name: Publish to PyPI
|
|
30
|
+
if: ${{ inputs.publish }}
|
|
31
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
togul-0.1.0/.gitignore
ADDED
togul-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Togul
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
togul-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: togul
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Togul feature flags and remote config
|
|
5
|
+
Project-URL: Homepage, https://togul.io
|
|
6
|
+
Project-URL: Documentation, https://docs.togul.io
|
|
7
|
+
Project-URL: Source, https://github.com/togulapp/togul-python
|
|
8
|
+
Author: Togul
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: feature-flags,feature-toggle,remote-config,togul
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx<1,>=0.24
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
31
|
+
Provides-Extra: django
|
|
32
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
33
|
+
Provides-Extra: fastapi
|
|
34
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Togul Python SDK
|
|
38
|
+
|
|
39
|
+
Official Python SDK for Togul feature flags.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install togul
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Framework integrations are extras:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install "togul[django]"
|
|
51
|
+
pip install "togul[fastapi]"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick start (sync)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
"""Minimal synchronous usage."""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import os
|
|
62
|
+
|
|
63
|
+
from togul import Config, TogulClient
|
|
64
|
+
|
|
65
|
+
config = Config(
|
|
66
|
+
api_key=os.environ["TOGUL_API_KEY"],
|
|
67
|
+
environment=os.environ.get("TOGUL_ENVIRONMENT", "production"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
with TogulClient(config) as client:
|
|
71
|
+
result = client.evaluate("new-dashboard", {"user_id": "u-123", "plan": "premium"})
|
|
72
|
+
print(f"{result.flag_key}: enabled={result.enabled} value={result.value!r} ({result.reason})")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`TogulClient` is blocking. Use it from Django views, Celery tasks, scripts, or
|
|
76
|
+
anywhere else that isn't running an event loop. The `with` block closes the
|
|
77
|
+
underlying HTTP connection pool on exit; if you pass your own `httpx.Client`,
|
|
78
|
+
the SDK leaves it open and you own its lifecycle.
|
|
79
|
+
|
|
80
|
+
See [`examples/basic.py`](examples/basic.py).
|
|
81
|
+
|
|
82
|
+
## Async
|
|
83
|
+
|
|
84
|
+
For FastAPI, Starlette, or any asyncio application, use `AsyncTogulClient`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from togul import AsyncTogulClient, Config
|
|
88
|
+
|
|
89
|
+
config = Config(api_key="...", environment="production")
|
|
90
|
+
|
|
91
|
+
async with AsyncTogulClient(config) as client:
|
|
92
|
+
result = await client.evaluate("new-dashboard", {"user_id": "u-123"})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The API mirrors `TogulClient` exactly, `evaluate` just needs an `await`.
|
|
96
|
+
`invalidate_cache()` and `invalidate_flag()` stay synchronous on both clients:
|
|
97
|
+
they only touch the in-memory cache under a lock, so there's nothing to await.
|
|
98
|
+
|
|
99
|
+
## Real-time updates
|
|
100
|
+
|
|
101
|
+
Both clients cache evaluation results for `cache_ttl` seconds. A stream
|
|
102
|
+
client keeps that cache fresh by invalidating entries as flags change,
|
|
103
|
+
instead of waiting for the TTL to expire.
|
|
104
|
+
|
|
105
|
+
Sync, run on a background thread:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import threading
|
|
109
|
+
|
|
110
|
+
from togul import Config, TogulClient, TogulStreamClient
|
|
111
|
+
|
|
112
|
+
config = Config(api_key="...", environment="production")
|
|
113
|
+
client = TogulClient(config)
|
|
114
|
+
stream = TogulStreamClient(config, client.cache)
|
|
115
|
+
|
|
116
|
+
threading.Thread(target=stream.connect, daemon=True).start()
|
|
117
|
+
# ... use client.evaluate(...) as usual; stream.stop() to shut it down.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Async, run as a task:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import asyncio
|
|
124
|
+
|
|
125
|
+
from togul import AsyncTogulClient, AsyncTogulStreamClient, Config
|
|
126
|
+
|
|
127
|
+
config = Config(api_key="...", environment="production")
|
|
128
|
+
client = AsyncTogulClient(config)
|
|
129
|
+
stream = AsyncTogulStreamClient(config, client.cache)
|
|
130
|
+
|
|
131
|
+
task = asyncio.create_task(stream.connect())
|
|
132
|
+
# ... await client.evaluate(...) as usual; stream.stop() then cancel the task.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`connect()` never returns on its own. It reconnects with exponential backoff
|
|
136
|
+
(starting at 1s, doubling up to a 30s cap, reset after a clean disconnect)
|
|
137
|
+
until `stop()` is called. A 401 or 403 response is the one exception: it is
|
|
138
|
+
re-raised out of `connect()` immediately, because reconnecting with a
|
|
139
|
+
rejected key cannot fix anything.
|
|
140
|
+
|
|
141
|
+
The FastAPI integration below wires the async stream client into the
|
|
142
|
+
application lifespan automatically; you don't need to manage the task by
|
|
143
|
+
hand there.
|
|
144
|
+
|
|
145
|
+
## Django
|
|
146
|
+
|
|
147
|
+
Add the middleware and a `TOGUL` settings block:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
MIDDLEWARE = [
|
|
151
|
+
# ... your other middleware ...
|
|
152
|
+
"togul.contrib.django.TogulMiddleware",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
TOGUL = {
|
|
156
|
+
"API_KEY": os.environ["TOGUL_API_KEY"],
|
|
157
|
+
"ENVIRONMENT": os.environ.get("TOGUL_ENVIRONMENT", "production"),
|
|
158
|
+
"CACHE_TTL": 30.0,
|
|
159
|
+
# "CONTEXT_BUILDER": "myapp.flags.build_context",
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
| Key | Default | Notes |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `API_KEY` | required | |
|
|
166
|
+
| `ENVIRONMENT` | required | |
|
|
167
|
+
| `TIMEOUT` | `5.0` | HTTP timeout in seconds. |
|
|
168
|
+
| `CACHE_TTL` | `30.0` | Seconds an evaluation stays cached. |
|
|
169
|
+
| `RETRY_COUNT` | `2` | Total request attempts (see below), not extra retries. |
|
|
170
|
+
| `CONTEXT_BUILDER` | the built-in `build_context` | Dotted path or callable; see below. |
|
|
171
|
+
|
|
172
|
+
There is deliberately no `BASE_URL` key. This matches `togul-laravel`'s
|
|
173
|
+
`config/togul.php`, which exposes the same narrow set of framework-layer
|
|
174
|
+
options. If you need a non-production origin, construct a `Config` directly
|
|
175
|
+
(see [`examples/basic.py`](examples/basic.py)) rather than going through
|
|
176
|
+
Django settings.
|
|
177
|
+
|
|
178
|
+
`TogulMiddleware` attaches `request.togul` (a `TogulClient`) and
|
|
179
|
+
`request.togul_context` (built by `CONTEXT_BUILDER`, or the default
|
|
180
|
+
`build_context` if unset) to every request. The default `build_context` maps
|
|
181
|
+
*only* the authenticated user's primary key to `user_id`, and omits it
|
|
182
|
+
entirely for an anonymous request rather than sending a blank value. If your
|
|
183
|
+
flags need anything else — a plan, a country, an experiment cohort — point
|
|
184
|
+
`CONTEXT_BUILDER` at your own function; don't edit the default.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
def dashboard(request):
|
|
188
|
+
result = request.togul.evaluate("new-dashboard", request.togul_context)
|
|
189
|
+
if result.enabled:
|
|
190
|
+
return render(request, "dashboard/new.html")
|
|
191
|
+
return render(request, "dashboard/legacy.html")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
For gating an entire view, use the `@togul_flag("key")` decorator instead.
|
|
195
|
+
It raises `Http404` when the flag is disabled, and works with or without the
|
|
196
|
+
middleware installed (it builds context on demand if `request.togul_context`
|
|
197
|
+
isn't there):
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from togul.contrib.django import togul_flag
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@togul_flag("new-dashboard")
|
|
204
|
+
def beta_dashboard(request):
|
|
205
|
+
return render(request, "dashboard/new.html")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
See [`examples/django_settings.py`](examples/django_settings.py).
|
|
209
|
+
|
|
210
|
+
## FastAPI
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from fastapi import Depends, FastAPI
|
|
214
|
+
|
|
215
|
+
from togul import AsyncTogulClient, Config
|
|
216
|
+
from togul.contrib.fastapi import create_lifespan, get_togul
|
|
217
|
+
|
|
218
|
+
config = Config(api_key="...", environment="production")
|
|
219
|
+
|
|
220
|
+
app = FastAPI(lifespan=create_lifespan(config))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.get("/dashboard")
|
|
224
|
+
async def dashboard(
|
|
225
|
+
user_id: str,
|
|
226
|
+
togul: AsyncTogulClient = Depends(get_togul),
|
|
227
|
+
) -> dict:
|
|
228
|
+
result = await togul.evaluate("new-dashboard", {"user_id": user_id})
|
|
229
|
+
return {"variant": "new" if result.enabled else "legacy", "reason": result.reason}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`create_lifespan(config, *, stream=True, client=None)` builds an
|
|
233
|
+
`AsyncTogulClient`, stores it on `app.state.togul`, and — since `stream`
|
|
234
|
+
defaults to `True` — starts an `AsyncTogulStreamClient` as a background task
|
|
235
|
+
so SSE events invalidate the cache in real time. Both are shut down cleanly
|
|
236
|
+
on application exit. Pass `client=` to supply your own pre-built
|
|
237
|
+
`AsyncTogulClient` (for tests, or a custom `httpx.AsyncClient`); the lifespan
|
|
238
|
+
still closes it on shutdown. Pass `stream=False` to skip the background
|
|
239
|
+
connection and rely on `cache_ttl` alone.
|
|
240
|
+
|
|
241
|
+
`get_togul` is a plain `Depends()` provider that reads `app.state.togul`.
|
|
242
|
+
|
|
243
|
+
See [`examples/fastapi_app.py`](examples/fastapi_app.py).
|
|
244
|
+
|
|
245
|
+
## Configuration reference
|
|
246
|
+
|
|
247
|
+
Every field of `Config`:
|
|
248
|
+
|
|
249
|
+
| Field | Type | Default | Meaning |
|
|
250
|
+
|---|---|---|---|
|
|
251
|
+
| `api_key` | `str` | required | Environment-scoped API key. |
|
|
252
|
+
| `environment` | `str` | required | Environment key, e.g. `"production"`. |
|
|
253
|
+
| `timeout` | `float` | `5.0` | HTTP request timeout, in seconds. |
|
|
254
|
+
| `cache_ttl` | `float` | `30.0` | How long an evaluation result stays cached, in seconds. |
|
|
255
|
+
| `retry_count` | `int` | `2` | **Total** request attempts, not extra retries — `2` means the SDK issues at most two requests, i.e. one retry. |
|
|
256
|
+
| `base_url` | `str` | `"https://api.togul.io"` | API origin. Trailing slashes are stripped. |
|
|
257
|
+
|
|
258
|
+
`Config` is a frozen dataclass: build a new one to change settings.
|
|
259
|
+
|
|
260
|
+
`evaluate()` is the only evaluation method — there are no typed
|
|
261
|
+
`evaluate_string` / `evaluate_boolean` helpers. Read `result.value` and
|
|
262
|
+
interpret it according to `result.value_type`.
|
|
263
|
+
|
|
264
|
+
## Caching and invalidation
|
|
265
|
+
|
|
266
|
+
Both clients keep an in-memory, thread-safe TTL cache of `EvaluateResult`s.
|
|
267
|
+
An entry is dropped lazily, the first time it's read after `cache_ttl`
|
|
268
|
+
seconds have passed, or if it carries a malformed (empty) `value_type`.
|
|
269
|
+
|
|
270
|
+
The cache key is `flag_key:environment`, followed by `:key=value` for every
|
|
271
|
+
context pair, with context keys sorted so call order never changes the key.
|
|
272
|
+
For example, `evaluate("new-dashboard", {"plan": "premium", "user_id": "u-123"})`
|
|
273
|
+
against environment `production` produces
|
|
274
|
+
`new-dashboard:production:plan=premium:user_id=u-123`.
|
|
275
|
+
|
|
276
|
+
`invalidate_cache()` clears the whole cache; `invalidate_flag(flag_key)`
|
|
277
|
+
clears only entries for that flag. A `TogulStreamClient` /
|
|
278
|
+
`AsyncTogulStreamClient` calls these automatically as SSE events arrive. A
|
|
279
|
+
line that is not a well-formed `data:` JSON object — a heartbeat, a blank
|
|
280
|
+
line, or a malformed payload — is ignored entirely: no invalidation, no
|
|
281
|
+
flush. A well-formed event naming a `flag_key` invalidates just that flag; a
|
|
282
|
+
well-formed event that omits it flushes the whole cache. Register
|
|
283
|
+
`on_cache_invalidated(...)` on the stream client to observe SSE-driven
|
|
284
|
+
invalidations, e.g. for logging — the stream client invalidates the cache
|
|
285
|
+
directly and only notifies its own listeners, so it never routes through the
|
|
286
|
+
evaluation client. A listener registered on the evaluation client instead
|
|
287
|
+
fires only for invalidations *you* trigger by calling that client's own
|
|
288
|
+
`invalidate_cache()` / `invalidate_flag()` directly; it will not fire for SSE
|
|
289
|
+
events.
|
|
290
|
+
|
|
291
|
+
## Error handling
|
|
292
|
+
|
|
293
|
+
Every error the SDK raises is a `TogulError`. `TogulAPIError` is the subclass
|
|
294
|
+
raised when the API itself returns a non-2xx response; it carries
|
|
295
|
+
`status_code` and, when the response body has one, `error_code`.
|
|
296
|
+
|
|
297
|
+
Retries apply only to HTTP `429` and any `5xx` response, plus network-level
|
|
298
|
+
failures. Every other `4xx` (400, 401, 403, 404, ...) raises immediately with
|
|
299
|
+
no retry. Each retry waits `attempt × 100ms` before trying again, so with the
|
|
300
|
+
default `retry_count` of 2 the single retry is delayed by 100ms. When
|
|
301
|
+
retries are exhausted, the client raises a `TogulError` wrapping the last
|
|
302
|
+
failure.
|
|
303
|
+
|
|
304
|
+
**There is no fail-open fallback.** Unlike SDKs that silently return a
|
|
305
|
+
default value when evaluation fails, this SDK never guesses on your behalf:
|
|
306
|
+
a failed `evaluate()` call raises, and it's up to your code to decide what
|
|
307
|
+
happens next — return a default, log and re-raise, fall back to cached
|
|
308
|
+
behavior, whatever fits your product. If you want fail-open behavior, wrap
|
|
309
|
+
`evaluate()` in a `try`/`except TogulError` yourself.
|
|
310
|
+
|
|
311
|
+
## Requirements
|
|
312
|
+
|
|
313
|
+
Python 3.9+. Django integration requires Django 4.2+. FastAPI integration
|
|
314
|
+
requires FastAPI 0.100+.
|
|
315
|
+
|
|
316
|
+
## License
|
|
317
|
+
|
|
318
|
+
MIT. See [LICENSE](LICENSE).
|