rocketflag 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rocketflag
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python SDK of the RocketFlag Service
5
5
  Author: RocketFlag Developers
6
6
  License: MIT
@@ -29,6 +29,8 @@ Dynamic: license-file
29
29
 
30
30
  Official Python SDK for [RocketFlag](https://rocketflag.app). Same evaluation API as the [Node](https://github.com/RocketFlag/node-sdk) and [Go](https://github.com/RocketFlag/go-sdk) SDKs: create a client, fetch a flag, branch on `flag.enabled`.
31
31
 
32
+ **Beta:** the evaluation API matches Node and Go, but packaging and docs may still change. Prefer pinning a version in production.
33
+
32
34
  ## Installation
33
35
 
34
36
  ```bash
@@ -81,7 +83,24 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
81
83
  })
82
84
  ```
83
85
 
84
- `env` must be alphanumeric, matching the Node SDK.
86
+ `env` selects the environment of a group flag. Environment names contain letters, numbers, hyphens and underscores.
87
+
88
+ ## Sticky rollouts and audiences
89
+
90
+ Pass a `targetingKey` to make percentage rollouts sticky, and any other keys as audience attributes:
91
+
92
+ ```python
93
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
94
+ "targetingKey": user.id,
95
+ "plan": "pro",
96
+ "country": "AU",
97
+ })
98
+ ```
99
+
100
+ - **`targetingKey`**: a stable identifier for the user. The same key always gets the same answer from a percentage rollout, in every environment of a group flag, and raising the percentage only ever adds users. Without a `targetingKey` the `cohort` is used, and with neither each request is a fresh random roll. Prefer an opaque id over an email address: the key is part of the request URL.
101
+ - **Any other key** is an audience attribute, matched against the flag's audience exactly and case-sensitively. An attribute you don't send never matches. `cohort`, `env` and `targetingKey` are reserved and can't be audience attributes.
102
+
103
+ Context values must be `str`, `int`, `float` or `bool`. `None` raises `ValueError`, so leave out an attribute you don't have. The `UserContext` type hint excludes `None` too, so a type checker catches it first.
85
104
 
86
105
  ## Caching
87
106
 
@@ -100,7 +119,11 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
100
119
  flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
101
120
  ```
102
121
 
103
- Without a client default or per-call TTL, every call hits the API. The cache has no size cap; entries expire on the next lookup after TTL.
122
+ Without a client default or per-call TTL, every call hits the API. Each distinct context is its own cache entry, so a `targetingKey` per user means an entry per user. The cache holds at most 10,000 entries and evicts the least recently used one when it is full. Change the cap with `max_entries`:
123
+
124
+ ```python
125
+ rocketflag = create_client(ttl_seconds=300, max_entries=50_000)
126
+ ```
104
127
 
105
128
  ## Errors
106
129
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  Official Python SDK for [RocketFlag](https://rocketflag.app). Same evaluation API as the [Node](https://github.com/RocketFlag/node-sdk) and [Go](https://github.com/RocketFlag/go-sdk) SDKs: create a client, fetch a flag, branch on `flag.enabled`.
4
4
 
5
+ **Beta:** the evaluation API matches Node and Go, but packaging and docs may still change. Prefer pinning a version in production.
6
+
5
7
  ## Installation
6
8
 
7
9
  ```bash
@@ -54,7 +56,24 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
54
56
  })
55
57
  ```
56
58
 
57
- `env` must be alphanumeric, matching the Node SDK.
59
+ `env` selects the environment of a group flag. Environment names contain letters, numbers, hyphens and underscores.
60
+
61
+ ## Sticky rollouts and audiences
62
+
63
+ Pass a `targetingKey` to make percentage rollouts sticky, and any other keys as audience attributes:
64
+
65
+ ```python
66
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
67
+ "targetingKey": user.id,
68
+ "plan": "pro",
69
+ "country": "AU",
70
+ })
71
+ ```
72
+
73
+ - **`targetingKey`**: a stable identifier for the user. The same key always gets the same answer from a percentage rollout, in every environment of a group flag, and raising the percentage only ever adds users. Without a `targetingKey` the `cohort` is used, and with neither each request is a fresh random roll. Prefer an opaque id over an email address: the key is part of the request URL.
74
+ - **Any other key** is an audience attribute, matched against the flag's audience exactly and case-sensitively. An attribute you don't send never matches. `cohort`, `env` and `targetingKey` are reserved and can't be audience attributes.
75
+
76
+ Context values must be `str`, `int`, `float` or `bool`. `None` raises `ValueError`, so leave out an attribute you don't have. The `UserContext` type hint excludes `None` too, so a type checker catches it first.
58
77
 
59
78
  ## Caching
60
79
 
@@ -73,7 +92,11 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
73
92
  flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
74
93
  ```
75
94
 
76
- Without a client default or per-call TTL, every call hits the API. The cache has no size cap; entries expire on the next lookup after TTL.
95
+ Without a client default or per-call TTL, every call hits the API. Each distinct context is its own cache entry, so a `targetingKey` per user means an entry per user. The cache holds at most 10,000 entries and evicts the least recently used one when it is full. Change the cap with `max_entries`:
96
+
97
+ ```python
98
+ rocketflag = create_client(ttl_seconds=300, max_entries=50_000)
99
+ ```
77
100
 
78
101
  ## Errors
79
102
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rocketflag"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Python SDK of the RocketFlag Service"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,18 +1,30 @@
1
1
  """Official Python SDK for RocketFlag."""
2
2
 
3
- from .client import DEFAULT_API_URL, DEFAULT_VERSION, Client, Flag, create_client
3
+ from .client import (
4
+ DEFAULT_API_URL,
5
+ DEFAULT_MAX_CACHE_ENTRIES,
6
+ DEFAULT_VERSION,
7
+ Client,
8
+ ContextValue,
9
+ Flag,
10
+ UserContext,
11
+ create_client,
12
+ )
4
13
  from .errors import APIError, InvalidResponseError, NetworkError, RocketFlagError
5
14
 
6
15
  __all__ = [
7
16
  "APIError",
8
17
  "Client",
18
+ "ContextValue",
9
19
  "DEFAULT_API_URL",
20
+ "DEFAULT_MAX_CACHE_ENTRIES",
10
21
  "DEFAULT_VERSION",
11
22
  "Flag",
12
23
  "InvalidResponseError",
13
24
  "NetworkError",
14
25
  "RocketFlagError",
26
+ "UserContext",
15
27
  "create_client",
16
28
  ]
17
29
 
18
- __version__ = "0.1.0"
30
+ __version__ = "0.2.0"
@@ -1,18 +1,21 @@
1
1
  """RocketFlag evaluation client.
2
2
 
3
3
  Mirrors the Node and Go SDKs: GET {api_url}/{version}/flags/{flag_id}
4
- with optional query-string user context (cohort, env, …) and an opt-in
5
- in-memory TTL cache keyed by flag ID + context.
4
+ with optional query-string user context (cohort, env, targetingKey and
5
+ audience attributes) and an opt-in in-memory TTL cache keyed by flag ID +
6
+ context, capped at a maximum number of entries.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
9
10
 
10
11
  import json
11
12
  import re
13
+ import threading
12
14
  import time
13
15
  import urllib.error
14
16
  import urllib.parse
15
17
  import urllib.request
18
+ from collections import OrderedDict
16
19
  from dataclasses import dataclass
17
20
  from typing import Any, Mapping, Optional, Union
18
21
 
@@ -20,9 +23,13 @@ from .errors import APIError, InvalidResponseError, NetworkError
20
23
 
21
24
  DEFAULT_API_URL = "https://api.rocketflag.app"
22
25
  DEFAULT_VERSION = "v1"
23
- _ALPHANUMERIC = re.compile(r"^[a-zA-Z0-9]+$")
26
+ DEFAULT_MAX_CACHE_ENTRIES = 10_000
27
+ _ENV_NAME = re.compile(r"[A-Za-z0-9_-]+")
24
28
 
25
29
  ContextValue = Union[str, int, float, bool]
30
+ # Every key is sent as a query parameter: cohort, env, targetingKey (a stable
31
+ # user identifier that makes percentage rollouts sticky) and any other key as
32
+ # an audience attribute. None is not a value; leave the key out instead.
26
33
  UserContext = Mapping[str, ContextValue]
27
34
 
28
35
 
@@ -48,12 +55,12 @@ def _validate_flag(payload: Any) -> Flag:
48
55
 
49
56
  def _validate_context(user_context: UserContext) -> None:
50
57
  for key, value in user_context.items():
51
- if isinstance(value, bool):
52
- continue
53
- if isinstance(value, (str, int, float)):
54
- if key == "env":
55
- if not isinstance(value, str) or not _ALPHANUMERIC.match(value):
56
- raise ValueError(f"env values must be alphanumeric. Invalid value for env: {value!r}")
58
+ if isinstance(value, (str, int, float)): # bool is an int
59
+ if key == "env" and (not isinstance(value, str) or not _ENV_NAME.fullmatch(value)):
60
+ raise ValueError(
61
+ "env values may only contain letters, numbers, hyphens and underscores. "
62
+ f"Invalid value for env: {value!r}"
63
+ )
57
64
  continue
58
65
  raise ValueError(
59
66
  f"userContext values must be of type str, int, float, or bool. Invalid value for key: {key}"
@@ -75,12 +82,18 @@ class Client:
75
82
  api_url: str = DEFAULT_API_URL,
76
83
  *,
77
84
  ttl_seconds: Optional[float] = None,
85
+ max_entries: int = DEFAULT_MAX_CACHE_ENTRIES,
78
86
  opener: Optional[urllib.request.OpenerDirector] = None,
79
87
  ) -> None:
88
+ if isinstance(max_entries, bool) or not isinstance(max_entries, int) or max_entries < 1:
89
+ raise ValueError("max_entries must be a positive integer")
80
90
  self.version = version
81
91
  self.api_url = api_url.rstrip("/")
82
92
  self._default_ttl_ms = 0.0 if ttl_seconds is None else float(ttl_seconds) * 1000.0
83
- self._cache: dict[str, tuple[Flag, float]] = {}
93
+ self._max_entries = max_entries
94
+ # Least recently used first; a hit moves its entry to the end.
95
+ self._cache: OrderedDict[str, tuple[Flag, float]] = OrderedDict()
96
+ self._cache_lock = threading.Lock()
84
97
  self._opener = opener or urllib.request.build_opener()
85
98
 
86
99
  def get_flag(
@@ -108,12 +121,14 @@ class Client:
108
121
  cache_key = ""
109
122
  if effective_ttl_ms > 0:
110
123
  cache_key = _cache_key(flag_id, params)
111
- entry = self._cache.get(cache_key)
112
- if entry is not None:
113
- flag, expires_at = entry
114
- if expires_at > time.time() * 1000.0:
115
- return Flag(name=flag.name, enabled=flag.enabled, id=flag.id)
116
- del self._cache[cache_key]
124
+ with self._cache_lock:
125
+ entry = self._cache.get(cache_key)
126
+ if entry is not None:
127
+ flag, expires_at = entry
128
+ if expires_at > time.time() * 1000.0:
129
+ self._cache.move_to_end(cache_key)
130
+ return Flag(name=flag.name, enabled=flag.enabled, id=flag.id)
131
+ del self._cache[cache_key]
117
132
 
118
133
  request = urllib.request.Request(url, method="GET")
119
134
  try:
@@ -139,7 +154,11 @@ class Client:
139
154
 
140
155
  flag = _validate_flag(payload)
141
156
  if effective_ttl_ms > 0:
142
- self._cache[cache_key] = (flag, time.time() * 1000.0 + effective_ttl_ms)
157
+ with self._cache_lock:
158
+ self._cache.pop(cache_key, None)
159
+ if len(self._cache) >= self._max_entries:
160
+ self._cache.popitem(last=False)
161
+ self._cache[cache_key] = (flag, time.time() * 1000.0 + effective_ttl_ms)
143
162
  return flag
144
163
 
145
164
 
@@ -147,6 +166,7 @@ def create_client(
147
166
  version: str = DEFAULT_VERSION,
148
167
  api_url: str = DEFAULT_API_URL,
149
168
  ttl_seconds: Optional[float] = None,
169
+ max_entries: int = DEFAULT_MAX_CACHE_ENTRIES,
150
170
  ) -> Client:
151
- """Create a client. Signature matches Node's createRocketflagClient(version, apiUrl, { ttlSeconds })."""
152
- return Client(version, api_url, ttl_seconds=ttl_seconds)
171
+ """Create a client. Matches Node's createRocketflagClient(version, apiUrl, { ttlSeconds, maxEntries })."""
172
+ return Client(version, api_url, ttl_seconds=ttl_seconds, max_entries=max_entries)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rocketflag
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python SDK of the RocketFlag Service
5
5
  Author: RocketFlag Developers
6
6
  License: MIT
@@ -29,6 +29,8 @@ Dynamic: license-file
29
29
 
30
30
  Official Python SDK for [RocketFlag](https://rocketflag.app). Same evaluation API as the [Node](https://github.com/RocketFlag/node-sdk) and [Go](https://github.com/RocketFlag/go-sdk) SDKs: create a client, fetch a flag, branch on `flag.enabled`.
31
31
 
32
+ **Beta:** the evaluation API matches Node and Go, but packaging and docs may still change. Prefer pinning a version in production.
33
+
32
34
  ## Installation
33
35
 
34
36
  ```bash
@@ -81,7 +83,24 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
81
83
  })
82
84
  ```
83
85
 
84
- `env` must be alphanumeric, matching the Node SDK.
86
+ `env` selects the environment of a group flag. Environment names contain letters, numbers, hyphens and underscores.
87
+
88
+ ## Sticky rollouts and audiences
89
+
90
+ Pass a `targetingKey` to make percentage rollouts sticky, and any other keys as audience attributes:
91
+
92
+ ```python
93
+ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", {
94
+ "targetingKey": user.id,
95
+ "plan": "pro",
96
+ "country": "AU",
97
+ })
98
+ ```
99
+
100
+ - **`targetingKey`**: a stable identifier for the user. The same key always gets the same answer from a percentage rollout, in every environment of a group flag, and raising the percentage only ever adds users. Without a `targetingKey` the `cohort` is used, and with neither each request is a fresh random roll. Prefer an opaque id over an email address: the key is part of the request URL.
101
+ - **Any other key** is an audience attribute, matched against the flag's audience exactly and case-sensitively. An attribute you don't send never matches. `cohort`, `env` and `targetingKey` are reserved and can't be audience attributes.
102
+
103
+ Context values must be `str`, `int`, `float` or `bool`. `None` raises `ValueError`, so leave out an attribute you don't have. The `UserContext` type hint excludes `None` too, so a type checker catches it first.
85
104
 
86
105
  ## Caching
87
106
 
@@ -100,7 +119,11 @@ flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=0)
100
119
  flag = rocketflag.get_flag("IFldMzqP5jtv9wAL", ttl_seconds=10)
101
120
  ```
102
121
 
103
- Without a client default or per-call TTL, every call hits the API. The cache has no size cap; entries expire on the next lookup after TTL.
122
+ Without a client default or per-call TTL, every call hits the API. Each distinct context is its own cache entry, so a `targetingKey` per user means an entry per user. The cache holds at most 10,000 entries and evicts the least recently used one when it is full. Change the cap with `max_entries`:
123
+
124
+ ```python
125
+ rocketflag = create_client(ttl_seconds=300, max_entries=50_000)
126
+ ```
104
127
 
105
128
  ## Errors
106
129
 
@@ -0,0 +1,266 @@
1
+ import json
2
+ import threading
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
+ import pytest
7
+
8
+ from rocketflag import (
9
+ DEFAULT_MAX_CACHE_ENTRIES,
10
+ APIError,
11
+ Client,
12
+ Flag,
13
+ InvalidResponseError,
14
+ NetworkError,
15
+ create_client,
16
+ )
17
+
18
+
19
+ class FlagHandler(BaseHTTPRequestHandler):
20
+ responses = {}
21
+ hits = 0
22
+
23
+ def do_GET(self):
24
+ type(self).hits += 1
25
+ parsed = urlparse(self.path)
26
+ body, status = self.responses.get(parsed.path, (b"{}", 404))
27
+ if callable(body):
28
+ body, status = body(parsed)
29
+ self.send_response(status)
30
+ self.send_header("Content-Type", "application/json")
31
+ self.end_headers()
32
+ self.wfile.write(body)
33
+
34
+ def log_message(self, format, *args):
35
+ return
36
+
37
+
38
+ @pytest.fixture
39
+ def server():
40
+ FlagHandler.hits = 0
41
+ FlagHandler.responses = {}
42
+ httpd = HTTPServer(("127.0.0.1", 0), FlagHandler)
43
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
44
+ thread.start()
45
+ host, port = httpd.server_address
46
+ yield f"http://{host}:{port}", FlagHandler
47
+ httpd.shutdown()
48
+ httpd.server_close()
49
+
50
+
51
+ def flag_json(**overrides):
52
+ payload = {"name": "signup", "enabled": True, "id": "abc123"}
53
+ payload.update(overrides)
54
+ return json.dumps(payload).encode()
55
+
56
+
57
+ def test_get_flag_success(server):
58
+ base, handler = server
59
+ handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
60
+ client = create_client(api_url=base)
61
+ flag = client.get_flag("abc123")
62
+ assert flag == Flag(name="signup", enabled=True, id="abc123")
63
+ assert flag.enabled is True
64
+
65
+
66
+ def test_passes_context_as_query_string(server):
67
+ base, handler = server
68
+ seen = {}
69
+
70
+ def body(parsed):
71
+ seen["query"] = parse_qs(parsed.query)
72
+ return flag_json(), 200
73
+
74
+ handler.responses["/v1/flags/abc123"] = (body, 200)
75
+ client = Client(api_url=base)
76
+ client.get_flag("abc123", {"cohort": "beta", "env": "prod"})
77
+ assert seen["query"]["cohort"] == ["beta"]
78
+ assert seen["query"]["env"] == ["prod"]
79
+
80
+
81
+ def test_cache_hits_on_second_call(server):
82
+ base, handler = server
83
+ handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
84
+ client = create_client(api_url=base, ttl_seconds=60)
85
+ first = client.get_flag("abc123", {"cohort": "beta"})
86
+ second = client.get_flag("abc123", {"cohort": "beta"})
87
+ assert first == second
88
+ assert handler.hits == 1
89
+
90
+
91
+ def test_ttl_zero_bypasses_cache(server):
92
+ base, handler = server
93
+ handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
94
+ client = create_client(api_url=base, ttl_seconds=60)
95
+ client.get_flag("abc123")
96
+ client.get_flag("abc123", ttl_seconds=0)
97
+ assert handler.hits == 2
98
+
99
+
100
+ def test_api_error_on_404(server):
101
+ base, handler = server
102
+ handler.responses["/v1/flags/missing"] = (b"not found", 404)
103
+ client = create_client(api_url=base)
104
+ with pytest.raises(APIError) as exc:
105
+ client.get_flag("missing")
106
+ assert exc.value.status == 404
107
+
108
+
109
+ def test_invalid_json(server):
110
+ base, handler = server
111
+ handler.responses["/v1/flags/abc123"] = (b"not-json", 200)
112
+ client = create_client(api_url=base)
113
+ with pytest.raises(InvalidResponseError):
114
+ client.get_flag("abc123")
115
+
116
+
117
+ def test_invalid_shape(server):
118
+ base, handler = server
119
+ handler.responses["/v1/flags/abc123"] = (b'{"name": 1}', 200)
120
+ client = create_client(api_url=base)
121
+ with pytest.raises(InvalidResponseError):
122
+ client.get_flag("abc123")
123
+
124
+
125
+ def test_requires_flag_id():
126
+ client = create_client()
127
+ with pytest.raises(ValueError):
128
+ client.get_flag("")
129
+
130
+
131
+ class StubResponse:
132
+ status = 200
133
+
134
+ def __enter__(self):
135
+ return self
136
+
137
+ def __exit__(self, *exc):
138
+ return False
139
+
140
+ def read(self):
141
+ return flag_json()
142
+
143
+
144
+ class StubOpener:
145
+ """Answers every request with a flag, recording the URLs it was asked for."""
146
+
147
+ def __init__(self):
148
+ self.urls = []
149
+
150
+ def open(self, request):
151
+ self.urls.append(request.full_url)
152
+ return StubResponse()
153
+
154
+
155
+ def test_passes_targeting_key_and_audience_attributes(server):
156
+ base, handler = server
157
+ seen = {}
158
+
159
+ def body(parsed):
160
+ seen["query"] = parse_qs(parsed.query, keep_blank_values=True)
161
+ return flag_json(), 200
162
+
163
+ handler.responses["/v1/flags/abc123"] = (body, 200)
164
+ client = Client(api_url=base)
165
+ client.get_flag("abc123", {"targetingKey": "user-42", "plan": "pro", "country": "AU", "seats": 5, "beta": True})
166
+ assert seen["query"] == {
167
+ "targetingKey": ["user-42"],
168
+ "plan": ["pro"],
169
+ "country": ["AU"],
170
+ "seats": ["5"],
171
+ "beta": ["true"],
172
+ }
173
+
174
+
175
+ def test_none_value_is_rejected():
176
+ opener = StubOpener()
177
+ client = Client(opener=opener)
178
+ with pytest.raises(ValueError, match="Invalid value for key: plan"):
179
+ client.get_flag("abc", {"plan": None})
180
+ assert opener.urls == []
181
+
182
+
183
+ @pytest.mark.parametrize("env", ["prod", "prod-portals", "staging_v2", "production1"])
184
+ def test_env_accepts_letters_numbers_hyphens_and_underscores(env):
185
+ opener = StubOpener()
186
+ client = Client(opener=opener)
187
+ client.get_flag("abc", {"env": env})
188
+ assert opener.urls == [f"https://api.rocketflag.app/v1/flags/abc?env={env}"]
189
+
190
+
191
+ @pytest.mark.parametrize("env", ["prod 1", "prod.1", "prod!", "prod\n", "", True, 5])
192
+ def test_env_rejects_other_characters(env):
193
+ opener = StubOpener()
194
+ client = Client(opener=opener)
195
+ with pytest.raises(ValueError, match="env values may only contain letters, numbers, hyphens and underscores"):
196
+ client.get_flag("abc", {"env": env})
197
+ assert opener.urls == []
198
+
199
+
200
+ def test_cache_evicts_least_recently_used_when_full():
201
+ opener = StubOpener()
202
+ client = Client(ttl_seconds=60, max_entries=2, opener=opener)
203
+ client.get_flag("abc", {"targetingKey": "a"})
204
+ client.get_flag("abc", {"targetingKey": "b"})
205
+ client.get_flag("abc", {"targetingKey": "a"}) # hit, so "b" is now least recently used
206
+ assert len(opener.urls) == 2
207
+
208
+ client.get_flag("abc", {"targetingKey": "c"}) # evicts "b"
209
+ client.get_flag("abc", {"targetingKey": "a"})
210
+ assert len(opener.urls) == 3
211
+
212
+ client.get_flag("abc", {"targetingKey": "b"})
213
+ assert len(opener.urls) == 4
214
+
215
+
216
+ def test_cache_holds_10000_entries_by_default():
217
+ assert DEFAULT_MAX_CACHE_ENTRIES == 10_000
218
+ opener = StubOpener()
219
+ client = Client(ttl_seconds=60, opener=opener)
220
+ for i in range(10_001):
221
+ client.get_flag("abc", {"targetingKey": f"user-{i}"})
222
+ assert len(opener.urls) == 10_001
223
+
224
+ client.get_flag("abc", {"targetingKey": "user-10000"})
225
+ assert len(opener.urls) == 10_001
226
+ client.get_flag("abc", {"targetingKey": "user-0"})
227
+ assert len(opener.urls) == 10_002
228
+
229
+
230
+ def test_cache_is_safe_under_concurrent_use():
231
+ opener = StubOpener()
232
+ client = Client(ttl_seconds=60, max_entries=8, opener=opener)
233
+ errors = []
234
+
235
+ def worker(n):
236
+ try:
237
+ for i in range(500):
238
+ client.get_flag("abc", {"targetingKey": f"user-{(n * i) % 20}"})
239
+ except Exception as exc: # pragma: no cover - surfaced by the assert below
240
+ errors.append(exc)
241
+
242
+ threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
243
+ for thread in threads:
244
+ thread.start()
245
+ for thread in threads:
246
+ thread.join()
247
+
248
+ assert errors == []
249
+ assert len(client._cache) <= 8
250
+
251
+
252
+ @pytest.mark.parametrize("max_entries", [0, -1, 1.5, True, "10"])
253
+ def test_max_entries_must_be_a_positive_integer(max_entries):
254
+ with pytest.raises(ValueError, match="max_entries must be a positive integer"):
255
+ Client(max_entries=max_entries)
256
+
257
+
258
+ def test_create_client_passes_max_entries():
259
+ with pytest.raises(ValueError, match="max_entries must be a positive integer"):
260
+ create_client(max_entries=0)
261
+
262
+
263
+ def test_network_error():
264
+ client = create_client(api_url="http://127.0.0.1:1")
265
+ with pytest.raises(NetworkError):
266
+ client.get_flag("abc123")
@@ -1,139 +0,0 @@
1
- import json
2
- import threading
3
- from http.server import BaseHTTPRequestHandler, HTTPServer
4
- from urllib.parse import parse_qs, urlparse
5
-
6
- import pytest
7
-
8
- from rocketflag import (
9
- APIError,
10
- Client,
11
- Flag,
12
- InvalidResponseError,
13
- NetworkError,
14
- create_client,
15
- )
16
-
17
-
18
- class FlagHandler(BaseHTTPRequestHandler):
19
- responses = {}
20
- hits = 0
21
-
22
- def do_GET(self):
23
- type(self).hits += 1
24
- parsed = urlparse(self.path)
25
- body, status = self.responses.get(parsed.path, (b"{}", 404))
26
- if callable(body):
27
- body, status = body(parsed)
28
- self.send_response(status)
29
- self.send_header("Content-Type", "application/json")
30
- self.end_headers()
31
- self.wfile.write(body)
32
-
33
- def log_message(self, format, *args):
34
- return
35
-
36
-
37
- @pytest.fixture
38
- def server():
39
- FlagHandler.hits = 0
40
- FlagHandler.responses = {}
41
- httpd = HTTPServer(("127.0.0.1", 0), FlagHandler)
42
- thread = threading.Thread(target=httpd.serve_forever, daemon=True)
43
- thread.start()
44
- host, port = httpd.server_address
45
- yield f"http://{host}:{port}", FlagHandler
46
- httpd.shutdown()
47
- httpd.server_close()
48
-
49
-
50
- def flag_json(**overrides):
51
- payload = {"name": "signup", "enabled": True, "id": "abc123"}
52
- payload.update(overrides)
53
- return json.dumps(payload).encode()
54
-
55
-
56
- def test_get_flag_success(server):
57
- base, handler = server
58
- handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
59
- client = create_client(api_url=base)
60
- flag = client.get_flag("abc123")
61
- assert flag == Flag(name="signup", enabled=True, id="abc123")
62
- assert flag.enabled is True
63
-
64
-
65
- def test_passes_context_as_query_string(server):
66
- base, handler = server
67
- seen = {}
68
-
69
- def body(parsed):
70
- seen["query"] = parse_qs(parsed.query)
71
- return flag_json(), 200
72
-
73
- handler.responses["/v1/flags/abc123"] = (body, 200)
74
- client = Client(api_url=base)
75
- client.get_flag("abc123", {"cohort": "beta", "env": "prod"})
76
- assert seen["query"]["cohort"] == ["beta"]
77
- assert seen["query"]["env"] == ["prod"]
78
-
79
-
80
- def test_cache_hits_on_second_call(server):
81
- base, handler = server
82
- handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
83
- client = create_client(api_url=base, ttl_seconds=60)
84
- first = client.get_flag("abc123", {"cohort": "beta"})
85
- second = client.get_flag("abc123", {"cohort": "beta"})
86
- assert first == second
87
- assert handler.hits == 1
88
-
89
-
90
- def test_ttl_zero_bypasses_cache(server):
91
- base, handler = server
92
- handler.responses["/v1/flags/abc123"] = (flag_json(), 200)
93
- client = create_client(api_url=base, ttl_seconds=60)
94
- client.get_flag("abc123")
95
- client.get_flag("abc123", ttl_seconds=0)
96
- assert handler.hits == 2
97
-
98
-
99
- def test_api_error_on_404(server):
100
- base, handler = server
101
- handler.responses["/v1/flags/missing"] = (b"not found", 404)
102
- client = create_client(api_url=base)
103
- with pytest.raises(APIError) as exc:
104
- client.get_flag("missing")
105
- assert exc.value.status == 404
106
-
107
-
108
- def test_invalid_json(server):
109
- base, handler = server
110
- handler.responses["/v1/flags/abc123"] = (b"not-json", 200)
111
- client = create_client(api_url=base)
112
- with pytest.raises(InvalidResponseError):
113
- client.get_flag("abc123")
114
-
115
-
116
- def test_invalid_shape(server):
117
- base, handler = server
118
- handler.responses["/v1/flags/abc123"] = (b'{"name": 1}', 200)
119
- client = create_client(api_url=base)
120
- with pytest.raises(InvalidResponseError):
121
- client.get_flag("abc123")
122
-
123
-
124
- def test_requires_flag_id():
125
- client = create_client()
126
- with pytest.raises(ValueError):
127
- client.get_flag("")
128
-
129
-
130
- def test_env_must_be_alphanumeric():
131
- client = create_client()
132
- with pytest.raises(ValueError, match="alphanumeric"):
133
- client.get_flag("abc", {"env": "prod-1"})
134
-
135
-
136
- def test_network_error():
137
- client = create_client(api_url="http://127.0.0.1:1")
138
- with pytest.raises(NetworkError):
139
- client.get_flag("abc123")
File without changes
File without changes