Skip to content

Commit 6a89dc1

Browse files
committed
fix: address provider lifecycle and release review
1 parent cd34253 commit 6a89dc1

6 files changed

Lines changed: 119 additions & 19 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@ jobs:
1818

1919
steps:
2020
- uses: actions/checkout@v4
21+
with:
22+
persist-credentials: false
2123
- uses: actions/setup-python@v5
2224
with:
2325
python-version: ${{ matrix.python-version }}
2426
- name: Install
2527
run: |
2628
python -m pip install --upgrade pip
2729
python -m pip install "openfeature-sdk>=0.10,<1" "build>=1,<2" "flake8>=7,<8" "pytest>=8,<10"
28-
python -m pip install "fb-python-sdk @ git+https://github.com/featbit/featbit-python-sdk.git@refs/pull/14/head"
30+
# Temporary immutable pin until fb-python-sdk 1.1.8 is published.
31+
python -m pip install "fb-python-sdk @ git+https://github.com/featbit/featbit-python-sdk.git@7c74e19bbb5934084802c0bee2cab6d39f4ec00d"
2932
python -m pip install -e . --no-deps
33+
python -m pip check
3034
- name: Lint
3135
run: python -m flake8 featbit_openfeature tests
3236
- name: Test

‎.github/workflows/release.yml‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
build:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
with:
16+
persist-credentials: false
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
- name: Build distributions
21+
run: |
22+
python -m pip install --upgrade pip
23+
python -m pip install "build>=1,<2"
24+
python -m build
25+
- name: Upload distributions
26+
uses: actions/upload-artifact@v4
27+
with:
28+
name: python-package-distributions
29+
path: dist/
30+
if-no-files-found: error
31+
32+
publish:
33+
needs: build
34+
runs-on: ubuntu-latest
35+
environment:
36+
name: pypi
37+
url: https://pypi.org/p/featbit-openfeature-server
38+
permissions:
39+
id-token: write
40+
steps:
41+
- name: Download distributions
42+
uses: actions/download-artifact@v4
43+
with:
44+
name: python-package-distributions
45+
path: dist/
46+
- name: Publish distributions to PyPI
47+
uses: pypa/gh-action-pypi-publish@release/v1

‎README.md‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,11 @@ details = client.get_boolean_details(
4848
print(details.value, details.reason, details.variant)
4949

5050
api.shutdown()
51-
fb_client.stop()
5251
```
5352

54-
The application owns the injected `FBClient`. Calling `api.shutdown()` removes
55-
provider listeners but does not stop the FeatBit client; call `fb_client.stop()`
56-
afterward so queued events are flushed and background resources are released.
53+
The provider owns the injected `FBClient`. Calling `api.shutdown()` removes
54+
provider listeners and stops the FeatBit client so queued events are flushed
55+
and background resources are released.
5756

5857
## Supported mappings
5958

@@ -93,9 +92,13 @@ FeatBit states are mapped as follows:
9392
| initializing | `NOT_READY` during provider initialization |
9493
| OK | `READY` |
9594
| interrupted | `STALE` |
96-
| permanently off | `FATAL` |
95+
| normal off during provider shutdown | `NOT_READY` |
96+
| permanently off with an error | `FATAL` |
9797
| provider shutdown | `NOT_READY` |
9898

99+
The included `py.typed` marker declares this package as PEP 561 typed, allowing
100+
type checkers to use the annotations shipped with the provider after install.
101+
99102
## Development tests
100103

101104
Unit tests use mocks, and the integration test uses FeatBit offline data. Neither
@@ -106,3 +109,14 @@ python -m pytest -q
106109
python -m flake8 featbit_openfeature tests
107110
python -m build
108111
```
112+
113+
## Release
114+
115+
Publishing is handled by `.github/workflows/release.yml` when a GitHub Release
116+
is published. The build and publish jobs are separated so package build code
117+
never receives an OpenID Connect token. The publish job uses PyPI Trusted
118+
Publishing and does not store a long-lived PyPI password in GitHub.
119+
120+
Before the first release, configure the PyPI trusted publisher with owner
121+
`featbit`, repository `openfeature-provider-python-server`, workflow
122+
`release.yml`, and GitHub environment `pypi`.

‎featbit_openfeature/provider.py‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ def on_flag_change(self, notice: FlagChangedNotice):
4646
class FeatBitProvider(AbstractProvider):
4747
"""OpenFeature provider backed by an existing thread-safe ``FBClient``.
4848
49-
The provider does not own the client. ``shutdown`` removes the listeners
50-
installed by the provider, but the application remains responsible for
51-
calling ``FBClient.stop`` when it no longer needs the SDK.
49+
The provider owns the injected client's lifecycle. ``shutdown`` removes
50+
its listeners and stops the client so queued events and background
51+
resources are released with the OpenFeature lifecycle.
5252
"""
5353

5454
def __init__(self, client: FBClient, initialization_timeout: float = 15.0):
@@ -72,6 +72,9 @@ def client(self) -> FBClient:
7272
return self._client
7373

7474
def initialize(self, evaluation_context: EvaluationContext) -> None:
75+
# Register before waiting so a client that connects just after an
76+
# initialization timeout can still publish its recovery to OpenFeature.
77+
self._register_listeners()
7578
if not self._client.initialize:
7679
ready = self._client.update_status_provider.wait_for_OKState(
7780
self._initialization_timeout
@@ -83,8 +86,6 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
8386
raise ProviderFatalError(message)
8487
raise ProviderNotReadyError(message)
8588

86-
self._register_listeners()
87-
8889
def shutdown(self) -> None:
8990
with self._lifecycle_lock:
9091
flag_listener = self._flag_change_listener
@@ -111,6 +112,13 @@ def shutdown(self) -> None:
111112
with self._lifecycle_lock:
112113
self._status_listener_registered = False
113114

115+
try:
116+
self._client.stop()
117+
except Exception:
118+
# Provider shutdown is application lifecycle code; an extension
119+
# failure in the underlying SDK must not escape into OpenFeature.
120+
logger.exception("Failed to stop FeatBit client")
121+
114122
def get_metadata(self) -> Metadata:
115123
return Metadata("featbit-openfeature-server")
116124

@@ -292,12 +300,17 @@ def _handle_data_update_status(self, state: State) -> None:
292300
ProviderEventDetails(message=self._state_message(state))
293301
)
294302
elif state.state_type == StateType.OFF:
295-
self.emit_provider_error(
296-
ProviderEventDetails(
297-
error_code=ErrorCode.PROVIDER_FATAL,
298-
message=self._state_message(state),
303+
if state.error_track is not None:
304+
self.emit_provider_error(
305+
ProviderEventDetails(
306+
error_code=ErrorCode.PROVIDER_FATAL,
307+
message=self._state_message(state),
308+
)
299309
)
300-
)
310+
# A normal OFF is produced by FBClient.stop(). Provider
311+
# shutdown unregisters this listener before stopping the
312+
# client; OpenFeature then removes the provider status, whose
313+
# standard fallback is NOT_READY. Do not misreport it FATAL.
301314
except Exception:
302315
logger.exception("Failed to emit OpenFeature provider status event")
303316

‎tests/test_offline_integration.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,3 @@ def test_openfeature_client_evaluates_with_real_featbit_sdk_offline():
4646
assert client.get_provider_status() == ProviderStatus.READY
4747
finally:
4848
api.shutdown()
49-
fb_client.stop()

‎tests/test_provider.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ def test_initialize_timeout_raises_provider_not_ready():
172172
with pytest.raises(ProviderNotReadyError):
173173
provider.initialize(EvaluationContext())
174174

175+
client.update_status_provider.add_listener.assert_called_once_with(
176+
provider._handle_data_update_status
177+
)
178+
175179

176180
def test_initialize_off_state_raises_provider_fatal():
177181
client = make_client(initialized=False)
@@ -207,7 +211,7 @@ def test_flag_change_is_emitted_and_shutdown_removes_listener():
207211
client.update_status_provider.remove_listener.assert_called_once_with(
208212
provider._handle_data_update_status
209213
)
210-
client.stop.assert_not_called()
214+
assert client.stop.call_count == 2
211215

212216

213217
def test_flag_change_callback_exception_never_escapes():
@@ -234,11 +238,22 @@ def test_shutdown_retries_listener_removal_after_failure():
234238
]
235239

236240
provider.shutdown()
237-
provider.initialize(EvaluationContext())
238241
provider.shutdown()
239242

240243
client.flag_tracker.add_flag_changed_listener.assert_called_once_with(listener)
241244
assert client.flag_tracker.remove_flag_change_notifier.call_count == 2
245+
assert client.stop.call_count == 2
246+
247+
248+
def test_shutdown_never_exposes_client_stop_failure():
249+
client = make_client()
250+
client.stop.side_effect = RuntimeError("boom")
251+
provider = FeatBitProvider(client)
252+
provider.initialize(EvaluationContext())
253+
254+
provider.shutdown()
255+
256+
client.stop.assert_called_once_with()
242257

243258

244259
def test_runtime_featbit_status_updates_openfeature_provider_status():
@@ -258,11 +273,19 @@ def test_runtime_featbit_status_updates_openfeature_provider_status():
258273
listener(State.ok_state())
259274
assert openfeature_client.get_provider_status() == ProviderStatus.READY
260275

276+
# A clean OFF is the result of lifecycle shutdown and must not be
277+
# reported as a fatal provider failure.
278+
listener(State.normal_off_state())
279+
assert openfeature_client.get_provider_status() == ProviderStatus.READY
280+
261281
listener(State.error_off_state("credential", "invalid secret"))
262282
assert openfeature_client.get_provider_status() == ProviderStatus.FATAL
263283
finally:
264284
api.shutdown()
265285

286+
assert openfeature_client.get_provider_status() == ProviderStatus.NOT_READY
287+
client.stop.assert_called_once_with()
288+
266289

267290
def test_status_callback_exception_never_escapes():
268291
client = make_client()

0 commit comments

Comments
 (0)