getfluxly 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.
- getfluxly-0.1.0/.gitignore +93 -0
- getfluxly-0.1.0/CHANGELOG.md +22 -0
- getfluxly-0.1.0/LICENSE +21 -0
- getfluxly-0.1.0/PKG-INFO +128 -0
- getfluxly-0.1.0/README.md +74 -0
- getfluxly-0.1.0/SECURITY.md +6 -0
- getfluxly-0.1.0/pyproject.toml +84 -0
- getfluxly-0.1.0/src/getfluxly/__init__.py +25 -0
- getfluxly-0.1.0/src/getfluxly/_http.py +226 -0
- getfluxly-0.1.0/src/getfluxly/_version.py +5 -0
- getfluxly-0.1.0/src/getfluxly/async_client.py +217 -0
- getfluxly-0.1.0/src/getfluxly/batch.py +77 -0
- getfluxly-0.1.0/src/getfluxly/client.py +225 -0
- getfluxly-0.1.0/src/getfluxly/errors.py +45 -0
- getfluxly-0.1.0/src/getfluxly/identity.py +64 -0
- getfluxly-0.1.0/src/getfluxly/py.typed +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.idea
|
|
3
|
+
|
|
4
|
+
# --------------------
|
|
5
|
+
# Django / Python
|
|
6
|
+
# --------------------
|
|
7
|
+
*.pyc
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.pyo
|
|
10
|
+
*.pyd
|
|
11
|
+
|
|
12
|
+
# Django local DB
|
|
13
|
+
db.sqlite3
|
|
14
|
+
db.sqlite3-journal
|
|
15
|
+
|
|
16
|
+
# Django secrets / env
|
|
17
|
+
.env
|
|
18
|
+
.env.*
|
|
19
|
+
*.env
|
|
20
|
+
|
|
21
|
+
# Logs
|
|
22
|
+
*.log
|
|
23
|
+
|
|
24
|
+
# --------------------
|
|
25
|
+
# Virtualenvs
|
|
26
|
+
# --------------------
|
|
27
|
+
venv/
|
|
28
|
+
.venv/
|
|
29
|
+
|
|
30
|
+
# --------------------
|
|
31
|
+
# IDE
|
|
32
|
+
# --------------------
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
|
|
36
|
+
# --------------------
|
|
37
|
+
# OS
|
|
38
|
+
# --------------------
|
|
39
|
+
.DS_Store
|
|
40
|
+
|
|
41
|
+
# --- Node ---
|
|
42
|
+
node_modules/
|
|
43
|
+
**/node_modules/
|
|
44
|
+
npm-debug.log*
|
|
45
|
+
yarn-debug.log*
|
|
46
|
+
pnpm-debug.log*
|
|
47
|
+
|
|
48
|
+
# --- Python ---
|
|
49
|
+
__pycache__/
|
|
50
|
+
*.py[cod]
|
|
51
|
+
.env
|
|
52
|
+
.venv
|
|
53
|
+
venv/
|
|
54
|
+
|
|
55
|
+
# --- Go ---
|
|
56
|
+
bin/
|
|
57
|
+
*.exe
|
|
58
|
+
*.out
|
|
59
|
+
/apps/workers/worker-*
|
|
60
|
+
|
|
61
|
+
# --- OS / IDE ---
|
|
62
|
+
.DS_Store
|
|
63
|
+
.idea/
|
|
64
|
+
.vscode/
|
|
65
|
+
|
|
66
|
+
# --- Pytest / coverage ---
|
|
67
|
+
.pytest_cache/
|
|
68
|
+
|
|
69
|
+
# --- SDK build output ---
|
|
70
|
+
/packages/browser/dist/
|
|
71
|
+
/packages/browser/dist-test/
|
|
72
|
+
/packages/next/dist/
|
|
73
|
+
/packages/node/dist/
|
|
74
|
+
/packages/react/dist/
|
|
75
|
+
/packages/sdk-js/dist/
|
|
76
|
+
/packages/sdk-js/dist-test/
|
|
77
|
+
.gh-tmp
|
|
78
|
+
|
|
79
|
+
# --- Local planning notes (untracked, personal) ---
|
|
80
|
+
/BETA_COMMUNITIES.md
|
|
81
|
+
/LAUNCH_PLAN_MAY18.md
|
|
82
|
+
/LINKEDIN_PAGE_SETUP.md
|
|
83
|
+
/SEO_AND_LAUNCH_DISTRIBUTION_PLAN.md
|
|
84
|
+
/Design Inspirations/
|
|
85
|
+
|
|
86
|
+
# --- Claude Code local config ---
|
|
87
|
+
.claude/
|
|
88
|
+
/packages/python/dist/
|
|
89
|
+
/packages/python/.venv/
|
|
90
|
+
/packages/python/*.egg-info/
|
|
91
|
+
/packages/ruby/*.gem
|
|
92
|
+
/packages/ruby/.bundle/
|
|
93
|
+
/packages/ruby/Gemfile.lock
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `getfluxly` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-05-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial release of `getfluxly` Python SDK.
|
|
12
|
+
- Synchronous `Client` and asynchronous `AsyncClient` against the
|
|
13
|
+
public REST API (`/v1/events/batch`, `/v1/identify/alias`).
|
|
14
|
+
- Batched event queue with configurable `flush_at`, `flush_interval`,
|
|
15
|
+
`max_queue_size`.
|
|
16
|
+
- Retry posture matching the Node SDK: 408 / 425 / 429 / 5xx with
|
|
17
|
+
exponential backoff and +/- 25% jitter, `Retry-After` honored.
|
|
18
|
+
- `X-Idempotency-Key` per batch so retries dedupe at the backend.
|
|
19
|
+
- `GFluxError` with stable `code` strings from `error-taxonomy.md`.
|
|
20
|
+
- `py.typed` marker for downstream consumers using `mypy`.
|
|
21
|
+
- Server-key-in-browser guard refuses construction in Pyodide /
|
|
22
|
+
Emscripten when a `gflux_secret_*` token is passed.
|
getfluxly-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GetFluxly
|
|
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.
|
getfluxly-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getfluxly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GetFluxly Python SDK — server-side events, identify, alias, with batching, retries, and idempotency.
|
|
5
|
+
Project-URL: Homepage, https://getfluxly.com
|
|
6
|
+
Project-URL: Documentation, https://docs.getfluxly.com/sdks/python
|
|
7
|
+
Project-URL: Repository, https://github.com/dineshmiriyala/getfluxly
|
|
8
|
+
Project-URL: Issues, https://github.com/dineshmiriyala/getfluxly/issues
|
|
9
|
+
Author-email: GetFluxly <hello@getfluxly.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 GetFluxly
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: analytics,cdp,events,getfluxly,telemetry
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Classifier: Typing :: Typed
|
|
43
|
+
Requires-Python: >=3.10
|
|
44
|
+
Requires-Dist: httpx>=0.27
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
47
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
52
|
+
Requires-Dist: twine>=5.1; extra == 'dev'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# getfluxly (Python)
|
|
56
|
+
|
|
57
|
+
Server-side Python SDK for [GetFluxly](https://getfluxly.com). Same surface as the Node SDK so observability across SDKs lives in one mental model.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install getfluxly
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick start (sync)
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from getfluxly import Client
|
|
67
|
+
|
|
68
|
+
client = Client(token="gflux_secret_yourtoken")
|
|
69
|
+
|
|
70
|
+
client.track("subscription_started",
|
|
71
|
+
external_id="user_42",
|
|
72
|
+
properties={"plan": "pro"})
|
|
73
|
+
|
|
74
|
+
client.identify(external_id="user_42",
|
|
75
|
+
traits={"email": "x@y.com", "plan": "pro"})
|
|
76
|
+
|
|
77
|
+
client.alias(user_id="user_42", anonymous_id="anon_a8f3c2")
|
|
78
|
+
|
|
79
|
+
client.flush()
|
|
80
|
+
client.shutdown() # also runs from atexit
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Quick start (async)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from getfluxly import AsyncClient
|
|
87
|
+
|
|
88
|
+
async with AsyncClient(token="gflux_secret_yourtoken") as ac:
|
|
89
|
+
await ac.track("subscription_started",
|
|
90
|
+
external_id="user_42",
|
|
91
|
+
properties={"plan": "pro"})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Defaults
|
|
95
|
+
|
|
96
|
+
| Option | Default | Notes |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| `flush_at` | 20 | Events queued before forced flush |
|
|
99
|
+
| `flush_interval` | 5.0 | Periodic flush cadence, seconds |
|
|
100
|
+
| `max_retries` | 2 | Per failed batch |
|
|
101
|
+
| `timeout` | 5.0 | Per HTTP request, seconds |
|
|
102
|
+
| `max_queue_size` | 1000 | Hard cap, raises `queue_overflow` |
|
|
103
|
+
|
|
104
|
+
Retries: 408, 425, 429, and 5xx with exponential backoff and +/- 25% jitter. `Retry-After` is honored. Each batch carries a unique `X-Idempotency-Key`.
|
|
105
|
+
|
|
106
|
+
## Errors
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from getfluxly import GFluxError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
client.track("invoice_paid", external_id="user_42")
|
|
113
|
+
except GFluxError as e:
|
|
114
|
+
if e.code == "queue_overflow":
|
|
115
|
+
... # back-pressure
|
|
116
|
+
elif e.retryable:
|
|
117
|
+
... # SDK already retried max_retries times
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Full code table at `docs.getfluxly.com/snippets/error-table`.
|
|
121
|
+
|
|
122
|
+
## Server keys never in browser
|
|
123
|
+
|
|
124
|
+
`Client(token="gflux_secret_...")` refuses to construct if it detects a Pyodide / Emscripten runtime, so a server-side script that accidentally ships to the browser fails loudly instead of leaking the key into client traffic.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT, see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# getfluxly (Python)
|
|
2
|
+
|
|
3
|
+
Server-side Python SDK for [GetFluxly](https://getfluxly.com). Same surface as the Node SDK so observability across SDKs lives in one mental model.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install getfluxly
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start (sync)
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from getfluxly import Client
|
|
13
|
+
|
|
14
|
+
client = Client(token="gflux_secret_yourtoken")
|
|
15
|
+
|
|
16
|
+
client.track("subscription_started",
|
|
17
|
+
external_id="user_42",
|
|
18
|
+
properties={"plan": "pro"})
|
|
19
|
+
|
|
20
|
+
client.identify(external_id="user_42",
|
|
21
|
+
traits={"email": "x@y.com", "plan": "pro"})
|
|
22
|
+
|
|
23
|
+
client.alias(user_id="user_42", anonymous_id="anon_a8f3c2")
|
|
24
|
+
|
|
25
|
+
client.flush()
|
|
26
|
+
client.shutdown() # also runs from atexit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start (async)
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from getfluxly import AsyncClient
|
|
33
|
+
|
|
34
|
+
async with AsyncClient(token="gflux_secret_yourtoken") as ac:
|
|
35
|
+
await ac.track("subscription_started",
|
|
36
|
+
external_id="user_42",
|
|
37
|
+
properties={"plan": "pro"})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Defaults
|
|
41
|
+
|
|
42
|
+
| Option | Default | Notes |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `flush_at` | 20 | Events queued before forced flush |
|
|
45
|
+
| `flush_interval` | 5.0 | Periodic flush cadence, seconds |
|
|
46
|
+
| `max_retries` | 2 | Per failed batch |
|
|
47
|
+
| `timeout` | 5.0 | Per HTTP request, seconds |
|
|
48
|
+
| `max_queue_size` | 1000 | Hard cap, raises `queue_overflow` |
|
|
49
|
+
|
|
50
|
+
Retries: 408, 425, 429, and 5xx with exponential backoff and +/- 25% jitter. `Retry-After` is honored. Each batch carries a unique `X-Idempotency-Key`.
|
|
51
|
+
|
|
52
|
+
## Errors
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from getfluxly import GFluxError
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
client.track("invoice_paid", external_id="user_42")
|
|
59
|
+
except GFluxError as e:
|
|
60
|
+
if e.code == "queue_overflow":
|
|
61
|
+
... # back-pressure
|
|
62
|
+
elif e.retryable:
|
|
63
|
+
... # SDK already retried max_retries times
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Full code table at `docs.getfluxly.com/snippets/error-table`.
|
|
67
|
+
|
|
68
|
+
## Server keys never in browser
|
|
69
|
+
|
|
70
|
+
`Client(token="gflux_secret_...")` refuses to construct if it detects a Pyodide / Emscripten runtime, so a server-side script that accidentally ships to the browser fails loudly instead of leaking the key into client traffic.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT, see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "getfluxly"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "GetFluxly Python SDK — server-side events, identify, alias, with batching, retries, and idempotency."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "GetFluxly", email = "hello@getfluxly.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"analytics",
|
|
15
|
+
"events",
|
|
16
|
+
"cdp",
|
|
17
|
+
"telemetry",
|
|
18
|
+
"getfluxly",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = ["httpx>=0.27"]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-asyncio>=0.23",
|
|
38
|
+
"respx>=0.21",
|
|
39
|
+
"mypy>=1.10",
|
|
40
|
+
"ruff>=0.6",
|
|
41
|
+
"build>=1.2",
|
|
42
|
+
"twine>=5.1",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://getfluxly.com"
|
|
47
|
+
Documentation = "https://docs.getfluxly.com/sdks/python"
|
|
48
|
+
Repository = "https://github.com/dineshmiriyala/getfluxly"
|
|
49
|
+
Issues = "https://github.com/dineshmiriyala/getfluxly/issues"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/getfluxly"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
include = [
|
|
56
|
+
"src/getfluxly",
|
|
57
|
+
"README.md",
|
|
58
|
+
"CHANGELOG.md",
|
|
59
|
+
"LICENSE",
|
|
60
|
+
"SECURITY.md",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 100
|
|
65
|
+
target-version = "py310"
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = ["E", "F", "I", "B", "UP", "ASYNC", "SIM"]
|
|
69
|
+
ignore = []
|
|
70
|
+
|
|
71
|
+
[tool.mypy]
|
|
72
|
+
strict = true
|
|
73
|
+
python_version = "3.10"
|
|
74
|
+
mypy_path = "src"
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
asyncio_mode = "auto"
|
|
78
|
+
testpaths = ["../../tests/python"]
|
|
79
|
+
# Stop conftest discovery from walking up into tests/conftest.py
|
|
80
|
+
# (a Django/Postgres-aware file used by the smoke + integration
|
|
81
|
+
# suites that imports psycopg). Our SDK suite is self-contained
|
|
82
|
+
# under tests/python/.
|
|
83
|
+
confcutdir = "../../tests/python"
|
|
84
|
+
norecursedirs = ["../../tests/smoke", "../../tests/integration", "../../tests/full_cycle", "../../tests/stress", "../../tests/unit", "../../tests/browser", "../../tests/node", "../../tests/react", "../../tests/next"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""GetFluxly Python SDK.
|
|
2
|
+
|
|
3
|
+
Server-side events, identify, and alias against the GetFluxly API.
|
|
4
|
+
Sync `Client` and `AsyncClient` share the same surface so application
|
|
5
|
+
code can pick whichever fits its runtime.
|
|
6
|
+
|
|
7
|
+
Defaults match the Node SDK so an event flowing through any GetFluxly
|
|
8
|
+
SDK shows the same batching / retry / idempotency posture.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._version import SDK_VERSION
|
|
12
|
+
from .async_client import AsyncClient
|
|
13
|
+
from .client import Client
|
|
14
|
+
from .errors import GFluxError
|
|
15
|
+
from .identity import AliasInput, IdentifyInput, TrackInput
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AsyncClient",
|
|
19
|
+
"AliasInput",
|
|
20
|
+
"Client",
|
|
21
|
+
"GFluxError",
|
|
22
|
+
"IdentifyInput",
|
|
23
|
+
"SDK_VERSION",
|
|
24
|
+
"TrackInput",
|
|
25
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Thin httpx wrapper with retry, jitter, and Retry-After.
|
|
2
|
+
|
|
3
|
+
Mirrors the Node SDK's retry posture: 408 / 425 / 429 / 5xx retried
|
|
4
|
+
with exponential backoff and +/- 25% jitter, ``Retry-After`` honored
|
|
5
|
+
when present. Each request carries the SDK identity header so server
|
|
6
|
+
logs can split traffic by SDK family + version.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._version import SDK_VERSION
|
|
20
|
+
from .errors import GFluxError
|
|
21
|
+
|
|
22
|
+
DEFAULT_API_HOST = "https://api.getfluxly.com"
|
|
23
|
+
SDK_LIBRARY = f"gflux-python/{SDK_VERSION}"
|
|
24
|
+
RETRY_STATUSES = {408, 425, 429}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_browser_runtime() -> bool:
|
|
28
|
+
"""Return True if we're running inside a browser-like Python runtime.
|
|
29
|
+
|
|
30
|
+
Used by ``Client.__init__`` to refuse the construction if a server
|
|
31
|
+
token is being initialized in Pyodide / Emscripten. Server keys
|
|
32
|
+
must never reach client bundles.
|
|
33
|
+
"""
|
|
34
|
+
return sys.platform == "emscripten" or "pyodide" in sys.modules
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generate_idempotency_key() -> str:
|
|
38
|
+
return str(uuid.uuid4())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _retry_after_ms(retry_after_header: str | None, attempt: int) -> int:
|
|
42
|
+
if retry_after_header:
|
|
43
|
+
try:
|
|
44
|
+
return int(float(retry_after_header) * 1000)
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
pass
|
|
47
|
+
base_ms = 200 * (2**attempt)
|
|
48
|
+
jitter = base_ms * 0.25
|
|
49
|
+
return int(base_ms + random.uniform(-jitter, jitter))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _should_retry(status: int) -> bool:
|
|
53
|
+
return status in RETRY_STATUSES or 500 <= status < 600
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_response(response: httpx.Response) -> dict[str, Any]:
|
|
57
|
+
text = response.text or ""
|
|
58
|
+
if not text:
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
parsed = json.loads(text)
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
if response.is_success:
|
|
64
|
+
raise GFluxError(
|
|
65
|
+
"gflux response body was not valid JSON",
|
|
66
|
+
code="invalid_response",
|
|
67
|
+
retryable=True,
|
|
68
|
+
status=response.status_code,
|
|
69
|
+
details={"snippet": text[:200]},
|
|
70
|
+
) from exc
|
|
71
|
+
return {"_raw": text[:200]}
|
|
72
|
+
return parsed if isinstance(parsed, dict) else {"_raw": parsed}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def http_error(response: httpx.Response, parsed: dict[str, Any]) -> GFluxError:
|
|
76
|
+
error_block = parsed.get("error")
|
|
77
|
+
if isinstance(error_block, dict):
|
|
78
|
+
code = error_block.get("code") or "server_error"
|
|
79
|
+
message = error_block.get("message") or response.reason_phrase
|
|
80
|
+
elif isinstance(error_block, str):
|
|
81
|
+
code = error_block
|
|
82
|
+
message = parsed.get("detail") or response.reason_phrase
|
|
83
|
+
else:
|
|
84
|
+
code = "server_error"
|
|
85
|
+
message = response.reason_phrase or "request failed"
|
|
86
|
+
retry_after = response.headers.get("Retry-After")
|
|
87
|
+
return GFluxError(
|
|
88
|
+
message,
|
|
89
|
+
code=code,
|
|
90
|
+
retryable=_should_retry(response.status_code),
|
|
91
|
+
retry_after_ms=_retry_after_ms(retry_after, 0) if retry_after else None,
|
|
92
|
+
status=response.status_code,
|
|
93
|
+
details=parsed,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def serialize(payload: Any) -> bytes:
|
|
98
|
+
return json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class HttpClient:
|
|
102
|
+
"""Sync httpx wrapper. Used by ``Client``."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
token: str,
|
|
108
|
+
api_host: str,
|
|
109
|
+
timeout: float,
|
|
110
|
+
max_retries: int,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.token = token
|
|
113
|
+
self.api_host = api_host.rstrip("/")
|
|
114
|
+
self.timeout = timeout
|
|
115
|
+
self.max_retries = max_retries
|
|
116
|
+
self._client = httpx.Client(timeout=timeout)
|
|
117
|
+
|
|
118
|
+
def post(
|
|
119
|
+
self,
|
|
120
|
+
url: str,
|
|
121
|
+
body: Any,
|
|
122
|
+
*,
|
|
123
|
+
idempotency_key: str | None = None,
|
|
124
|
+
request_id: str | None = None,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
headers = {
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
"Authorization": f"Bearer {self.token}",
|
|
129
|
+
"X-GFlux-SDK": SDK_LIBRARY,
|
|
130
|
+
}
|
|
131
|
+
if idempotency_key:
|
|
132
|
+
headers["X-Idempotency-Key"] = idempotency_key
|
|
133
|
+
if request_id:
|
|
134
|
+
headers["X-Request-Id"] = request_id
|
|
135
|
+
|
|
136
|
+
payload = serialize(body)
|
|
137
|
+
|
|
138
|
+
last_error: Exception | None = None
|
|
139
|
+
for attempt in range(self.max_retries + 1):
|
|
140
|
+
try:
|
|
141
|
+
response = self._client.post(url, content=payload, headers=headers)
|
|
142
|
+
except httpx.HTTPError as exc:
|
|
143
|
+
last_error = exc
|
|
144
|
+
if attempt < self.max_retries:
|
|
145
|
+
continue
|
|
146
|
+
raise GFluxError(
|
|
147
|
+
f"network error after {attempt + 1} attempts: {exc}",
|
|
148
|
+
code="transport_error",
|
|
149
|
+
retryable=True,
|
|
150
|
+
) from exc
|
|
151
|
+
|
|
152
|
+
parsed = parse_response(response)
|
|
153
|
+
if response.is_success:
|
|
154
|
+
return parsed
|
|
155
|
+
if _should_retry(response.status_code) and attempt < self.max_retries:
|
|
156
|
+
continue
|
|
157
|
+
raise http_error(response, parsed)
|
|
158
|
+
|
|
159
|
+
# Unreachable; the loop above either returns or raises.
|
|
160
|
+
assert last_error is not None
|
|
161
|
+
raise GFluxError("postJson exited without a result", code="internal_error")
|
|
162
|
+
|
|
163
|
+
def close(self) -> None:
|
|
164
|
+
self._client.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class AsyncHttpClient:
|
|
168
|
+
"""Async httpx wrapper. Used by ``AsyncClient``."""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
token: str,
|
|
174
|
+
api_host: str,
|
|
175
|
+
timeout: float,
|
|
176
|
+
max_retries: int,
|
|
177
|
+
) -> None:
|
|
178
|
+
self.token = token
|
|
179
|
+
self.api_host = api_host.rstrip("/")
|
|
180
|
+
self.timeout = timeout
|
|
181
|
+
self.max_retries = max_retries
|
|
182
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
183
|
+
|
|
184
|
+
async def post(
|
|
185
|
+
self,
|
|
186
|
+
url: str,
|
|
187
|
+
body: Any,
|
|
188
|
+
*,
|
|
189
|
+
idempotency_key: str | None = None,
|
|
190
|
+
request_id: str | None = None,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
headers = {
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
"Authorization": f"Bearer {self.token}",
|
|
195
|
+
"X-GFlux-SDK": SDK_LIBRARY,
|
|
196
|
+
}
|
|
197
|
+
if idempotency_key:
|
|
198
|
+
headers["X-Idempotency-Key"] = idempotency_key
|
|
199
|
+
if request_id:
|
|
200
|
+
headers["X-Request-Id"] = request_id
|
|
201
|
+
|
|
202
|
+
payload = serialize(body)
|
|
203
|
+
|
|
204
|
+
for attempt in range(self.max_retries + 1):
|
|
205
|
+
try:
|
|
206
|
+
response = await self._client.post(url, content=payload, headers=headers)
|
|
207
|
+
except httpx.HTTPError as exc:
|
|
208
|
+
if attempt < self.max_retries:
|
|
209
|
+
continue
|
|
210
|
+
raise GFluxError(
|
|
211
|
+
f"network error after {attempt + 1} attempts: {exc}",
|
|
212
|
+
code="transport_error",
|
|
213
|
+
retryable=True,
|
|
214
|
+
) from exc
|
|
215
|
+
|
|
216
|
+
parsed = parse_response(response)
|
|
217
|
+
if response.is_success:
|
|
218
|
+
return parsed
|
|
219
|
+
if _should_retry(response.status_code) and attempt < self.max_retries:
|
|
220
|
+
continue
|
|
221
|
+
raise http_error(response, parsed)
|
|
222
|
+
|
|
223
|
+
raise GFluxError("postJson exited without a result", code="internal_error")
|
|
224
|
+
|
|
225
|
+
async def aclose(self) -> None:
|
|
226
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Async ``AsyncClient`` for asyncio runtimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._http import (
|
|
9
|
+
DEFAULT_API_HOST,
|
|
10
|
+
AsyncHttpClient,
|
|
11
|
+
generate_idempotency_key,
|
|
12
|
+
is_browser_runtime,
|
|
13
|
+
)
|
|
14
|
+
from .batch import (
|
|
15
|
+
EventEnvelope,
|
|
16
|
+
EventQueue,
|
|
17
|
+
FlushResult,
|
|
18
|
+
combine,
|
|
19
|
+
empty_flush_result,
|
|
20
|
+
)
|
|
21
|
+
from .errors import GFluxError
|
|
22
|
+
from .identity import (
|
|
23
|
+
AliasInput,
|
|
24
|
+
require_alias_source,
|
|
25
|
+
require_one_id,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncClient:
|
|
30
|
+
"""Asynchronous GetFluxly client.
|
|
31
|
+
|
|
32
|
+
Use as an ``async with`` context manager — the manager calls
|
|
33
|
+
``aclose()`` on exit which flushes any buffered events.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
token: str,
|
|
40
|
+
api_host: str = DEFAULT_API_HOST,
|
|
41
|
+
flush_at: int = 20,
|
|
42
|
+
flush_interval: float = 5.0,
|
|
43
|
+
max_retries: int = 2,
|
|
44
|
+
timeout: float = 5.0,
|
|
45
|
+
max_queue_size: int = 1000,
|
|
46
|
+
) -> None:
|
|
47
|
+
if not token:
|
|
48
|
+
raise GFluxError("token is required", code="validation_error")
|
|
49
|
+
if token.startswith("gflux_secret_") and is_browser_runtime():
|
|
50
|
+
raise GFluxError(
|
|
51
|
+
"server token detected in a browser-like Python runtime; "
|
|
52
|
+
"use a publishable key or move this code server-side",
|
|
53
|
+
code="server_key_in_browser",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self._token = token
|
|
57
|
+
self._api_host = api_host.rstrip("/")
|
|
58
|
+
self._flush_at = flush_at
|
|
59
|
+
self._flush_interval = flush_interval
|
|
60
|
+
|
|
61
|
+
self._queue = EventQueue(max_size=max_queue_size)
|
|
62
|
+
self._http = AsyncHttpClient(
|
|
63
|
+
token=token,
|
|
64
|
+
api_host=self._api_host,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
max_retries=max_retries,
|
|
67
|
+
)
|
|
68
|
+
self._lock = asyncio.Lock()
|
|
69
|
+
self._closed = False
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------ public
|
|
72
|
+
|
|
73
|
+
async def __aenter__(self) -> AsyncClient:
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
async def __aexit__(self, *_: object) -> None:
|
|
77
|
+
await self.aclose()
|
|
78
|
+
|
|
79
|
+
async def track(
|
|
80
|
+
self,
|
|
81
|
+
event: str,
|
|
82
|
+
*,
|
|
83
|
+
anonymous_id: str | None = None,
|
|
84
|
+
external_id: str | None = None,
|
|
85
|
+
user_id: str | None = None,
|
|
86
|
+
properties: dict[str, Any] | None = None,
|
|
87
|
+
timestamp: str | None = None,
|
|
88
|
+
context: dict[str, Any] | None = None,
|
|
89
|
+
) -> FlushResult | None:
|
|
90
|
+
require_one_id(
|
|
91
|
+
anonymous_id=anonymous_id,
|
|
92
|
+
external_id=external_id,
|
|
93
|
+
user_id=user_id,
|
|
94
|
+
)
|
|
95
|
+
payload: dict[str, Any] = {"event": event}
|
|
96
|
+
if anonymous_id:
|
|
97
|
+
payload["anonymous_id"] = anonymous_id
|
|
98
|
+
if external_id:
|
|
99
|
+
payload["external_id"] = external_id
|
|
100
|
+
if user_id:
|
|
101
|
+
payload["user_id"] = user_id
|
|
102
|
+
if properties is not None:
|
|
103
|
+
payload["properties"] = properties
|
|
104
|
+
if timestamp is not None:
|
|
105
|
+
payload["timestamp"] = timestamp
|
|
106
|
+
if context is not None:
|
|
107
|
+
payload["context"] = context
|
|
108
|
+
return await self._enqueue(payload)
|
|
109
|
+
|
|
110
|
+
async def identify(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
anonymous_id: str | None = None,
|
|
114
|
+
external_id: str | None = None,
|
|
115
|
+
user_id: str | None = None,
|
|
116
|
+
traits: dict[str, Any] | None = None,
|
|
117
|
+
) -> FlushResult | None:
|
|
118
|
+
require_one_id(
|
|
119
|
+
anonymous_id=anonymous_id,
|
|
120
|
+
external_id=external_id,
|
|
121
|
+
user_id=user_id,
|
|
122
|
+
)
|
|
123
|
+
payload: dict[str, Any] = {"event": "$identify"}
|
|
124
|
+
if anonymous_id:
|
|
125
|
+
payload["anonymous_id"] = anonymous_id
|
|
126
|
+
if external_id:
|
|
127
|
+
payload["external_id"] = external_id
|
|
128
|
+
if user_id:
|
|
129
|
+
payload["user_id"] = user_id
|
|
130
|
+
if traits is not None:
|
|
131
|
+
payload["traits"] = traits
|
|
132
|
+
return await self._enqueue(payload)
|
|
133
|
+
|
|
134
|
+
async def alias(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
user_id: str,
|
|
138
|
+
anonymous_id: str | None = None,
|
|
139
|
+
previous_id: str | None = None,
|
|
140
|
+
request_id: str | None = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
require_alias_source(
|
|
143
|
+
AliasInput(
|
|
144
|
+
user_id=user_id,
|
|
145
|
+
anonymous_id=anonymous_id,
|
|
146
|
+
previous_id=previous_id,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
body: dict[str, Any] = {"user_id": user_id}
|
|
150
|
+
if anonymous_id:
|
|
151
|
+
body["anonymous_id"] = anonymous_id
|
|
152
|
+
if previous_id:
|
|
153
|
+
body["previous_id"] = previous_id
|
|
154
|
+
response = await self._http.post(
|
|
155
|
+
f"{self._api_host}/v1/identify/alias",
|
|
156
|
+
body,
|
|
157
|
+
idempotency_key=generate_idempotency_key(),
|
|
158
|
+
request_id=request_id,
|
|
159
|
+
)
|
|
160
|
+
alias_obj = response.get("alias")
|
|
161
|
+
if not alias_obj:
|
|
162
|
+
raise GFluxError(
|
|
163
|
+
"alias response did not include alias data",
|
|
164
|
+
code="invalid_response",
|
|
165
|
+
retryable=True,
|
|
166
|
+
details=response,
|
|
167
|
+
)
|
|
168
|
+
return alias_obj if isinstance(alias_obj, dict) else {"_raw": alias_obj}
|
|
169
|
+
|
|
170
|
+
async def flush(self) -> FlushResult:
|
|
171
|
+
result = empty_flush_result()
|
|
172
|
+
while True:
|
|
173
|
+
async with self._lock:
|
|
174
|
+
batch = self._queue.drain(self._flush_at)
|
|
175
|
+
if not batch:
|
|
176
|
+
break
|
|
177
|
+
try:
|
|
178
|
+
response = await self._http.post(
|
|
179
|
+
f"{self._api_host}/v1/events/batch",
|
|
180
|
+
{"events": [e.payload for e in batch]},
|
|
181
|
+
idempotency_key=generate_idempotency_key(),
|
|
182
|
+
)
|
|
183
|
+
except GFluxError as exc:
|
|
184
|
+
if exc.retryable:
|
|
185
|
+
async with self._lock:
|
|
186
|
+
self._queue.requeue_front(batch)
|
|
187
|
+
raise
|
|
188
|
+
result = combine(
|
|
189
|
+
result,
|
|
190
|
+
FlushResult(
|
|
191
|
+
accepted=int(response.get("accepted", len(batch))),
|
|
192
|
+
rejected=int(response.get("rejected", 0)),
|
|
193
|
+
batches=1,
|
|
194
|
+
errors=list(response.get("errors", [])),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
async def aclose(self) -> FlushResult:
|
|
200
|
+
async with self._lock:
|
|
201
|
+
if self._closed:
|
|
202
|
+
return empty_flush_result()
|
|
203
|
+
self._closed = True
|
|
204
|
+
try:
|
|
205
|
+
return await self.flush()
|
|
206
|
+
finally:
|
|
207
|
+
await self._http.aclose()
|
|
208
|
+
|
|
209
|
+
# ----------------------------------------------------------------- private
|
|
210
|
+
|
|
211
|
+
async def _enqueue(self, payload: dict[str, Any]) -> FlushResult | None:
|
|
212
|
+
async with self._lock:
|
|
213
|
+
self._queue.enqueue(EventEnvelope(payload=payload))
|
|
214
|
+
should_flush = len(self._queue) >= self._flush_at
|
|
215
|
+
if should_flush:
|
|
216
|
+
return await self.flush()
|
|
217
|
+
return None
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared event-queue / flush logic. Used by both Client and AsyncClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import GFluxError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class FlushResult:
|
|
14
|
+
accepted: int
|
|
15
|
+
rejected: int
|
|
16
|
+
batches: int
|
|
17
|
+
errors: list[dict[str, Any]] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EventEnvelope:
|
|
22
|
+
"""A single event ready for the wire.
|
|
23
|
+
|
|
24
|
+
Stays as a plain dict-style payload so the HTTP layer can JSON-
|
|
25
|
+
encode without further translation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
payload: dict[str, Any]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EventQueue:
|
|
32
|
+
"""A bounded queue of events awaiting flush.
|
|
33
|
+
|
|
34
|
+
``max_size`` is a hard cap. Enqueueing beyond it raises
|
|
35
|
+
``queue_overflow`` so the caller can apply back-pressure rather
|
|
36
|
+
than silently dropping events.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, max_size: int) -> None:
|
|
40
|
+
self._dq: deque[EventEnvelope] = deque()
|
|
41
|
+
self._max_size = max_size
|
|
42
|
+
|
|
43
|
+
def __len__(self) -> int:
|
|
44
|
+
return len(self._dq)
|
|
45
|
+
|
|
46
|
+
def enqueue(self, env: EventEnvelope) -> None:
|
|
47
|
+
if len(self._dq) >= self._max_size:
|
|
48
|
+
raise GFluxError(
|
|
49
|
+
f"event queue is full ({self._max_size}); flush before enqueueing more",
|
|
50
|
+
code="queue_overflow",
|
|
51
|
+
retryable=False,
|
|
52
|
+
details={"max_size": self._max_size},
|
|
53
|
+
)
|
|
54
|
+
self._dq.append(env)
|
|
55
|
+
|
|
56
|
+
def drain(self, max_events: int) -> list[EventEnvelope]:
|
|
57
|
+
out: list[EventEnvelope] = []
|
|
58
|
+
for _ in range(min(max_events, len(self._dq))):
|
|
59
|
+
out.append(self._dq.popleft())
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
def requeue_front(self, envs: list[EventEnvelope]) -> None:
|
|
63
|
+
for env in reversed(envs):
|
|
64
|
+
self._dq.appendleft(env)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def empty_flush_result() -> FlushResult:
|
|
68
|
+
return FlushResult(accepted=0, rejected=0, batches=0, errors=[])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def combine(a: FlushResult, b: FlushResult) -> FlushResult:
|
|
72
|
+
return FlushResult(
|
|
73
|
+
accepted=a.accepted + b.accepted,
|
|
74
|
+
rejected=a.rejected + b.rejected,
|
|
75
|
+
batches=a.batches + b.batches,
|
|
76
|
+
errors=a.errors + b.errors,
|
|
77
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Sync ``Client`` for server-side use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import contextlib
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._http import (
|
|
11
|
+
DEFAULT_API_HOST,
|
|
12
|
+
HttpClient,
|
|
13
|
+
generate_idempotency_key,
|
|
14
|
+
is_browser_runtime,
|
|
15
|
+
)
|
|
16
|
+
from .batch import (
|
|
17
|
+
EventEnvelope,
|
|
18
|
+
EventQueue,
|
|
19
|
+
FlushResult,
|
|
20
|
+
combine,
|
|
21
|
+
empty_flush_result,
|
|
22
|
+
)
|
|
23
|
+
from .errors import GFluxError
|
|
24
|
+
from .identity import (
|
|
25
|
+
AliasInput,
|
|
26
|
+
require_alias_source,
|
|
27
|
+
require_one_id,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Client:
|
|
32
|
+
"""Synchronous GetFluxly client.
|
|
33
|
+
|
|
34
|
+
Construction is cheap. The HTTP client is lazy — the first ``track``
|
|
35
|
+
or ``identify`` call wires up the queue; ``flush`` and ``shutdown``
|
|
36
|
+
drive the network round-trips.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
token: str,
|
|
43
|
+
api_host: str = DEFAULT_API_HOST,
|
|
44
|
+
flush_at: int = 20,
|
|
45
|
+
flush_interval: float = 5.0,
|
|
46
|
+
max_retries: int = 2,
|
|
47
|
+
timeout: float = 5.0,
|
|
48
|
+
max_queue_size: int = 1000,
|
|
49
|
+
) -> None:
|
|
50
|
+
if not token:
|
|
51
|
+
raise GFluxError("token is required", code="validation_error")
|
|
52
|
+
if token.startswith("gflux_secret_") and is_browser_runtime():
|
|
53
|
+
raise GFluxError(
|
|
54
|
+
"server token detected in a browser-like Python runtime; "
|
|
55
|
+
"use a publishable key or move this code server-side",
|
|
56
|
+
code="server_key_in_browser",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self._token = token
|
|
60
|
+
self._api_host = api_host.rstrip("/")
|
|
61
|
+
self._flush_at = flush_at
|
|
62
|
+
self._flush_interval = flush_interval
|
|
63
|
+
self._max_queue_size = max_queue_size
|
|
64
|
+
|
|
65
|
+
self._queue = EventQueue(max_size=max_queue_size)
|
|
66
|
+
self._http = HttpClient(
|
|
67
|
+
token=token,
|
|
68
|
+
api_host=self._api_host,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
max_retries=max_retries,
|
|
71
|
+
)
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
self._shutdown = False
|
|
74
|
+
|
|
75
|
+
atexit.register(self._safe_shutdown)
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ public
|
|
78
|
+
|
|
79
|
+
def track(
|
|
80
|
+
self,
|
|
81
|
+
event: str,
|
|
82
|
+
*,
|
|
83
|
+
anonymous_id: str | None = None,
|
|
84
|
+
external_id: str | None = None,
|
|
85
|
+
user_id: str | None = None,
|
|
86
|
+
properties: dict[str, Any] | None = None,
|
|
87
|
+
timestamp: str | None = None,
|
|
88
|
+
context: dict[str, Any] | None = None,
|
|
89
|
+
) -> FlushResult | None:
|
|
90
|
+
require_one_id(
|
|
91
|
+
anonymous_id=anonymous_id,
|
|
92
|
+
external_id=external_id,
|
|
93
|
+
user_id=user_id,
|
|
94
|
+
)
|
|
95
|
+
payload: dict[str, Any] = {"event": event}
|
|
96
|
+
if anonymous_id:
|
|
97
|
+
payload["anonymous_id"] = anonymous_id
|
|
98
|
+
if external_id:
|
|
99
|
+
payload["external_id"] = external_id
|
|
100
|
+
if user_id:
|
|
101
|
+
payload["user_id"] = user_id
|
|
102
|
+
if properties is not None:
|
|
103
|
+
payload["properties"] = properties
|
|
104
|
+
if timestamp is not None:
|
|
105
|
+
payload["timestamp"] = timestamp
|
|
106
|
+
if context is not None:
|
|
107
|
+
payload["context"] = context
|
|
108
|
+
return self._enqueue(payload)
|
|
109
|
+
|
|
110
|
+
def identify(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
anonymous_id: str | None = None,
|
|
114
|
+
external_id: str | None = None,
|
|
115
|
+
user_id: str | None = None,
|
|
116
|
+
traits: dict[str, Any] | None = None,
|
|
117
|
+
) -> FlushResult | None:
|
|
118
|
+
require_one_id(
|
|
119
|
+
anonymous_id=anonymous_id,
|
|
120
|
+
external_id=external_id,
|
|
121
|
+
user_id=user_id,
|
|
122
|
+
)
|
|
123
|
+
payload: dict[str, Any] = {"event": "$identify"}
|
|
124
|
+
if anonymous_id:
|
|
125
|
+
payload["anonymous_id"] = anonymous_id
|
|
126
|
+
if external_id:
|
|
127
|
+
payload["external_id"] = external_id
|
|
128
|
+
if user_id:
|
|
129
|
+
payload["user_id"] = user_id
|
|
130
|
+
if traits is not None:
|
|
131
|
+
payload["traits"] = traits
|
|
132
|
+
return self._enqueue(payload)
|
|
133
|
+
|
|
134
|
+
def alias(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
user_id: str,
|
|
138
|
+
anonymous_id: str | None = None,
|
|
139
|
+
previous_id: str | None = None,
|
|
140
|
+
request_id: str | None = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
require_alias_source(
|
|
143
|
+
AliasInput(
|
|
144
|
+
user_id=user_id,
|
|
145
|
+
anonymous_id=anonymous_id,
|
|
146
|
+
previous_id=previous_id,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
body: dict[str, Any] = {"user_id": user_id}
|
|
150
|
+
if anonymous_id:
|
|
151
|
+
body["anonymous_id"] = anonymous_id
|
|
152
|
+
if previous_id:
|
|
153
|
+
body["previous_id"] = previous_id
|
|
154
|
+
url = f"{self._api_host}/v1/identify/alias"
|
|
155
|
+
response = self._http.post(
|
|
156
|
+
url,
|
|
157
|
+
body,
|
|
158
|
+
idempotency_key=generate_idempotency_key(),
|
|
159
|
+
request_id=request_id,
|
|
160
|
+
)
|
|
161
|
+
alias_obj = response.get("alias")
|
|
162
|
+
if not alias_obj:
|
|
163
|
+
raise GFluxError(
|
|
164
|
+
"alias response did not include alias data",
|
|
165
|
+
code="invalid_response",
|
|
166
|
+
retryable=True,
|
|
167
|
+
details=response,
|
|
168
|
+
)
|
|
169
|
+
return alias_obj if isinstance(alias_obj, dict) else {"_raw": alias_obj}
|
|
170
|
+
|
|
171
|
+
def flush(self) -> FlushResult:
|
|
172
|
+
result = empty_flush_result()
|
|
173
|
+
while True:
|
|
174
|
+
with self._lock:
|
|
175
|
+
batch = self._queue.drain(self._flush_at)
|
|
176
|
+
if not batch:
|
|
177
|
+
break
|
|
178
|
+
try:
|
|
179
|
+
response = self._http.post(
|
|
180
|
+
f"{self._api_host}/v1/events/batch",
|
|
181
|
+
{"events": [e.payload for e in batch]},
|
|
182
|
+
idempotency_key=generate_idempotency_key(),
|
|
183
|
+
)
|
|
184
|
+
except GFluxError as exc:
|
|
185
|
+
if exc.retryable:
|
|
186
|
+
# Requeue so a later flush retries. Same key would
|
|
187
|
+
# collide; let the next flush mint a new one.
|
|
188
|
+
with self._lock:
|
|
189
|
+
self._queue.requeue_front(batch)
|
|
190
|
+
raise
|
|
191
|
+
result = combine(
|
|
192
|
+
result,
|
|
193
|
+
FlushResult(
|
|
194
|
+
accepted=int(response.get("accepted", len(batch))),
|
|
195
|
+
rejected=int(response.get("rejected", 0)),
|
|
196
|
+
batches=1,
|
|
197
|
+
errors=list(response.get("errors", [])),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
def shutdown(self) -> FlushResult:
|
|
203
|
+
with self._lock:
|
|
204
|
+
if self._shutdown:
|
|
205
|
+
return empty_flush_result()
|
|
206
|
+
self._shutdown = True
|
|
207
|
+
try:
|
|
208
|
+
return self.flush()
|
|
209
|
+
finally:
|
|
210
|
+
self._http.close()
|
|
211
|
+
|
|
212
|
+
# ----------------------------------------------------------------- private
|
|
213
|
+
|
|
214
|
+
def _enqueue(self, payload: dict[str, Any]) -> FlushResult | None:
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._queue.enqueue(EventEnvelope(payload=payload))
|
|
217
|
+
should_flush = len(self._queue) >= self._flush_at
|
|
218
|
+
if should_flush:
|
|
219
|
+
return self.flush()
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _safe_shutdown(self) -> None:
|
|
223
|
+
# atexit: never raise on interpreter shutdown
|
|
224
|
+
with contextlib.suppress(Exception):
|
|
225
|
+
self.shutdown()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Typed error surface for the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GFluxError(Exception):
|
|
9
|
+
"""Every error raised by the SDK is a ``GFluxError``.
|
|
10
|
+
|
|
11
|
+
The ``code`` attribute is one of the strings from
|
|
12
|
+
docs/sdk-standards/error-taxonomy.md and is stable across SDK
|
|
13
|
+
versions. ``retryable`` indicates whether the SDK already attempted
|
|
14
|
+
a retry, so callers know "wait and retry yourself" vs "fix the
|
|
15
|
+
payload".
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
code: str
|
|
19
|
+
retryable: bool
|
|
20
|
+
retry_after_ms: int | None
|
|
21
|
+
status: int | None
|
|
22
|
+
details: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str,
|
|
27
|
+
*,
|
|
28
|
+
code: str,
|
|
29
|
+
retryable: bool = False,
|
|
30
|
+
retry_after_ms: int | None = None,
|
|
31
|
+
status: int | None = None,
|
|
32
|
+
details: dict[str, Any] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
self.retryable = retryable
|
|
37
|
+
self.retry_after_ms = retry_after_ms
|
|
38
|
+
self.status = status
|
|
39
|
+
self.details = details or {}
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return (
|
|
43
|
+
f"GFluxError(code={self.code!r}, retryable={self.retryable}, "
|
|
44
|
+
f"status={self.status}, message={super().__str__()!r})"
|
|
45
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Input dataclasses for ``track``, ``identify``, and ``alias``.
|
|
2
|
+
|
|
3
|
+
Field names mirror the wire shape: ``external_id`` (snake_case),
|
|
4
|
+
``anonymous_id``, etc. These are the same names the public REST API
|
|
5
|
+
accepts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import GFluxError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TrackInput:
|
|
18
|
+
event: str
|
|
19
|
+
anonymous_id: str | None = None
|
|
20
|
+
external_id: str | None = None
|
|
21
|
+
user_id: str | None = None
|
|
22
|
+
properties: dict[str, Any] | None = None
|
|
23
|
+
timestamp: str | None = None
|
|
24
|
+
context: dict[str, Any] | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class IdentifyInput:
|
|
29
|
+
anonymous_id: str | None = None
|
|
30
|
+
external_id: str | None = None
|
|
31
|
+
user_id: str | None = None
|
|
32
|
+
traits: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class AliasInput:
|
|
37
|
+
user_id: str
|
|
38
|
+
anonymous_id: str | None = None
|
|
39
|
+
previous_id: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def require_one_id(
|
|
43
|
+
*, anonymous_id: str | None, external_id: str | None, user_id: str | None
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Mirror the API's identity rule: at least one identifier must be set."""
|
|
46
|
+
|
|
47
|
+
if not any([anonymous_id, external_id, user_id]):
|
|
48
|
+
raise GFluxError(
|
|
49
|
+
"track / identify requires at least one of anonymous_id, external_id, user_id",
|
|
50
|
+
code="validation_error",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_alias_source(input: AliasInput) -> None:
|
|
55
|
+
if not input.user_id:
|
|
56
|
+
raise GFluxError(
|
|
57
|
+
"alias requires user_id",
|
|
58
|
+
code="validation_error",
|
|
59
|
+
)
|
|
60
|
+
if not (input.anonymous_id or input.previous_id):
|
|
61
|
+
raise GFluxError(
|
|
62
|
+
"alias requires anonymous_id or previous_id",
|
|
63
|
+
code="validation_error",
|
|
64
|
+
)
|
|
File without changes
|