Skip to content

Commit dfb82d3

Browse files
committed
Add ConfigStore support
Mirroring the JS SDK, we provide a simple get() interface. I opted to not trying to provide a dict facade or similar. That is possible but it doesn't seem to represent a significant ergonomic improvement and might make it more likely that user's fail to keep in mind the potential exception behavior. As with the Rust SDK, we intelligently use hint information from the host to get values with a big enough buffer if our original size is insufficient.
1 parent b364b13 commit dfb82d3

6 files changed

Lines changed: 455 additions & 1 deletion

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ EXAMPLES_DIR := examples
2828
COMPUTE_WIT := wit/deps/fastly/compute.wit
2929

3030
# Define all available examples (add new ones here)
31-
EXAMPLES := bottle-app flask-app backend-requests game-of-life
31+
EXAMPLES := bottle-app flask-app backend-requests game-of-life config-store
3232

3333
# Default example for serve target
3434
EXAMPLE ?= bottle-app
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Config Store example application.
2+
3+
Demonstrates Fastly Config Store usage with minimal test endpoints.
4+
"""
5+
6+
import json
7+
import traceback
8+
from typing import Any
9+
10+
from bottle import Bottle, response
11+
12+
from fastly_compute.config_store import ConfigStore
13+
from fastly_compute.wsgi import WsgiHttpIncoming
14+
15+
app = Bottle()
16+
17+
18+
def json_response(data: dict[str, Any], status_code: int = 200) -> str:
19+
"""Create a JSON response."""
20+
response.content_type = "application/json"
21+
response.status = status_code
22+
return json.dumps(data, indent=2)
23+
24+
25+
def handle_request(handler):
26+
"""Decorator to handle common request/response patterns."""
27+
28+
def wrapper(*args, **kwargs):
29+
try:
30+
result = handler(*args, **kwargs)
31+
return json_response(result)
32+
except Exception as e:
33+
return json_response(
34+
{
35+
"error": repr(e),
36+
"error_type": type(e).__name__,
37+
"traceback": traceback.format_exc(),
38+
},
39+
status_code=500,
40+
)
41+
42+
return wrapper
43+
44+
45+
@app.route("/get/<store_name>/<key>")
46+
@app.route("/get/<store_name>/<key>/<default>")
47+
@handle_request
48+
def test_get(store_name, key, default=None):
49+
"""Proxy endpoint to issue ConfigStore gets with optional default."""
50+
with ConfigStore.open(store_name) as config:
51+
value = config.get(key, default)
52+
return {"value": value}
53+
54+
55+
@app.route("/get_with_initial_buf_len/<store_name>/<key>/<initial_buf_len:int>")
56+
@handle_request
57+
def test_get_with_initial_buf_len(store_name, key, initial_buf_len):
58+
"""Proxy endpoint to test get with custom initial_buf_len using raw API."""
59+
config = ConfigStore.open(store_name)
60+
# Use _get_raw to test buffer sizing without automatic retry
61+
value = config._get_raw(key, initial_buf_len)
62+
return {"value": value}
63+
64+
65+
@app.route("/contains/<store_name>/<key>")
66+
@handle_request
67+
def test_contains(store_name, key):
68+
"""Proxy endpoint to test contains."""
69+
config = ConfigStore.open(store_name)
70+
contains = config.contains(key)
71+
return {"contains": contains}
72+
73+
74+
# Create the HTTP handler for Fastly Compute
75+
HttpIncoming = WsgiHttpIncoming(app)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[project]
2+
name = "config-store"
3+
version = "0.1.0"
4+
description = "Fastly Compute example demonstrating Config Store usage"
5+
requires-python = ">=3.12"
6+
dependencies = [
7+
"bottle>=0.12.25",
8+
"fastly-compute",
9+
]
10+
11+
[tool.uv.sources]
12+
fastly-compute = { path = "../../", editable = true }
13+
14+
[tool.fastly-compute]
15+
entry = "config-store"

examples/config-store/uv.lock

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

fastly_compute/config_store.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Config Store API for Fastly Compute.
2+
3+
This module provides access to Fastly Config Stores, which allow you to store
4+
configuration data that can be updated without redeploying your service.
5+
6+
Example::
7+
8+
from fastly_compute.config_store import ConfigStore
9+
10+
with ConfigStore.open("my-config") as config:
11+
api_url = config.get("api_url", "https://api.example.com")
12+
"""
13+
14+
from wit_world.imports import config_store as wit_config_store
15+
from wit_world.imports.types import Error_BufferLen, Error_InvalidArgument, OpenError
16+
17+
from fastly_compute.exceptions import FastlyError, remap_wit_errors
18+
19+
20+
class ConfigStoreError(FastlyError):
21+
"""Base exception for all config store errors."""
22+
23+
24+
class ConfigStoreNotFoundError(ConfigStoreError):
25+
"""The requested config store does not exist."""
26+
27+
28+
class ConfigStoreInvalidNameError(ConfigStoreError):
29+
"""The config store name is invalid."""
30+
31+
32+
class ConfigStoreBufferTooSmallError(ConfigStoreError):
33+
"""The buffer provided was too small for the config value.
34+
35+
The required buffer size is available in the `required_size` attribute.
36+
"""
37+
38+
def __init__(self, error: Error_BufferLen):
39+
"""Initialize with the WIT error containing the required size.
40+
41+
:param error: The WIT Error_BufferLen dataclass with the required size
42+
"""
43+
self.required_size = error.value
44+
super().__init__(
45+
f"Buffer too small for config store value. Required size: {self.required_size} bytes"
46+
)
47+
48+
49+
class ConfigStoreInvalidKeyError(ConfigStoreError):
50+
"""The key provided is invalid."""
51+
52+
53+
class ConfigStore:
54+
"""Interface to Fastly Config Store.
55+
56+
Config Stores provide read-only access to configuration data that can be
57+
updated without redeploying your service.
58+
59+
Example::
60+
61+
with ConfigStore.open("app-config") as config:
62+
api_url = config.get("api_url", "https://api.example.com")
63+
"""
64+
65+
def __init__(self, store: wit_config_store.Store):
66+
"""Private constructor. Use ConfigStore.open() instead."""
67+
self._store = store
68+
69+
@classmethod
70+
@remap_wit_errors(
71+
{
72+
OpenError.NOT_FOUND: ConfigStoreNotFoundError,
73+
OpenError.INVALID_SYNTAX: ConfigStoreInvalidNameError,
74+
OpenError.NAME_TOO_LONG: ConfigStoreInvalidNameError,
75+
OpenError.RESERVED: ConfigStoreInvalidNameError,
76+
}
77+
)
78+
def open(cls, name: str) -> "ConfigStore":
79+
"""Open a config store by name.
80+
81+
:param name: The name of the config store
82+
:return: ConfigStore instance
83+
:raises ConfigStoreNotFoundError: If the config store doesn't exist
84+
:raises ConfigStoreInvalidNameError: If the name is invalid or too long
85+
86+
Example::
87+
88+
config = ConfigStore.open("my-config")
89+
"""
90+
store = wit_config_store.Store.open(name)
91+
return cls(store)
92+
93+
def get(
94+
self, key: str, default: str | None = None, initial_buf_len: int = 1024
95+
) -> str | None:
96+
"""Get a configuration value with automatic buffer resizing.
97+
98+
This method automatically handles buffer sizing for config store values.
99+
If the initial buffer is too small, it will automatically retry once with
100+
the exact size required by the host.
101+
102+
:param key: The configuration key
103+
:param default: Default value if key not found
104+
:param initial_buf_len: Initial buffer size hint in bytes (default: 1024).
105+
This can be tuned for performance if you know your values are typically
106+
larger or smaller than 1KB.
107+
:return: Configuration value or default if not found
108+
:raises ConfigStoreInvalidKeyError: If the key is invalid
109+
:raises ConfigStoreBufferTooSmallError: If the value is too large even after
110+
automatic resizing (should not happen unless there's a host-level bug)
111+
112+
Example::
113+
114+
config = ConfigStore.open("app-config")
115+
# Basic usage with default buffer size
116+
api_url = config.get("api_url", "https://api.example.com")
117+
118+
# Optimize for large values by using a larger initial buffer
119+
large_value = config.get("large_config", initial_buf_len=16384)
120+
"""
121+
# Try with the initial buffer size
122+
try:
123+
result = self._get_raw(key, initial_buf_len)
124+
except ConfigStoreBufferTooSmallError as e:
125+
# Buffer was too small. The exception contains the exact required size.
126+
# Retry ONCE with the exact size needed. If this second attempt fails,
127+
# let the exception propagate (no infinite recursion).
128+
result = self._get_raw(key, e.required_size)
129+
130+
if result is None:
131+
result = default
132+
133+
return result
134+
135+
@remap_wit_errors(
136+
{
137+
Error_BufferLen: ConfigStoreBufferTooSmallError,
138+
Error_InvalidArgument: ConfigStoreInvalidKeyError,
139+
}
140+
)
141+
def _get_raw(self, key: str, max_len: int) -> str | None:
142+
"""Internal method to get a value with a specific buffer size.
143+
144+
:param key: The configuration key
145+
:param max_len: Maximum buffer length
146+
:return: Configuration value or None if not found
147+
:raises ConfigStoreBufferTooSmallError: If the buffer is too small
148+
:raises ConfigStoreInvalidKeyError: If the key is invalid
149+
"""
150+
return self._store.get(key, max_len)
151+
152+
def contains(self, key: str) -> bool:
153+
"""Check if a key exists in the config store.
154+
155+
Uses a zero-length buffer to efficiently check for key existence without
156+
retrieving the value.
157+
158+
:param key: The configuration key
159+
:return: True if the key exists, False otherwise
160+
:raises ConfigStoreInvalidKeyError: If the key is invalid
161+
162+
Example::
163+
164+
config = ConfigStore.open("app-config")
165+
if config.contains("feature_flag"):
166+
print("Feature flag exists")
167+
"""
168+
try:
169+
# Use a 0-length buffer to check existence without retrieving the value
170+
result = self._get_raw(key, 0)
171+
return result is not None
172+
except ConfigStoreBufferTooSmallError:
173+
# Buffer too small means the key exists with a non-empty value
174+
return True
175+
except ConfigStoreInvalidKeyError:
176+
# Re-raise invalid key errors
177+
raise
178+
179+
def close(self) -> None:
180+
"""Explicitly close the config store, releasing its resources.
181+
182+
This is called automatically when using the config store as a context
183+
manager. If not called explicitly, resources will eventually be freed
184+
by the garbage collector.
185+
"""
186+
self._store.__exit__(None, None, None)
187+
188+
def __enter__(self) -> "ConfigStore":
189+
"""Context manager entry.
190+
191+
Allows use of ConfigStore in a 'with' statement.
192+
"""
193+
return self
194+
195+
def __exit__(self, exc_type, exc_val, exc_tb):
196+
"""Context manager exit."""
197+
self._store.__exit__(exc_type, exc_val, exc_tb)

0 commit comments

Comments
 (0)