Skip to content

Commit

Permalink
feat: Overhaul settings
Browse files Browse the repository at this point in the history
 - Move example defaults to app code
 - Add self-documenting `_seconds` prefix to relevant settings
 - Add configurable entrypoint
 - Add ability to write defaults to `CONFIG_PATH`
 - Improve code structure
 - Update documentation
  • Loading branch information
khvn26 committed Apr 16, 2024
1 parent efbe02e commit a5af729
Show file tree
Hide file tree
Showing 11 changed files with 281 additions and 139 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ checkstyle.txt
.idea
*.iml
__pycache__
*.egg-info
*.egg-info
config.json
9 changes: 4 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ ENV VIRTUAL_ENV=/opt/venv
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"

COPY requirements.lock config.json /app/
RUN pip install --no-cache-dir --upgrade -r requirements.lock

COPY ./src /app/
COPY src /app/src
COPY requirements.lock pyproject.toml /app/
RUN pip install --no-cache-dir -r requirements.lock && edge-proxy-config

EXPOSE 8000

USER nobody

CMD ["uvicorn", "edge_proxy.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["edge-proxy-serve"]
85 changes: 83 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,87 @@ The main benefit to running the Edge Proxy is that you reduce your polling reque

The main benefit to running server side SDKs in [Local Evaluation Mode](https://docs.flagsmith.com/clients/overview#2---local-evaluation) is that you get the lowest possible latency.

## Useful Links
## Local development

[Documentation](https://docs.flagsmith.com/advanced-use/edge-proxy)
### Prerequisites

- [Rye](https://rye-up.com/guide/installation/)
- [Docker](https://docs.docker.com/engine/install/)

### Setup local environment

Install locked dependencies:

`rye sync --no-lock`

Install pre-commit hooks:

`pre-commit install`

Run tests:

`rye test`


### Build and run Docker image locally

```shell
# Build image
docker build . -t edge-proxy-local

# Run image
docker run --rm \
-p 8000:8000 \
edge-proxy-local
```

## Configuration

See complete configuration [reference](https://docs.flagsmith.com/deployment/hosting/locally-edge-proxy).

Edge Proxy expects to load configuration from `./config.json`.

Create an example configuration by running the `edge-proxy-config` entrypoint:

```sh
rye run edge-proxy-config
```

This will output the example configuration to stdout and write it to `./config.json`.

Here's how to mount the file into Edge Proxy's Docker container:

```sh
docker run -v ./config.json:/app/config.json flagsmith/edge-proxy:latest
```

You can specify custom path to `config.json`, e.g.:

```sh
export CONFIG_PATH=/<path-to-config>/config.json

edge-proxy-config # Will write an example configuration to custom path.
edge-proxy-serve # Will read configuration from custom path.
```

You can also mount to custom path inside container:

```sh
docker run \
-e CONFIG_PATH=/var/foo.json \
-v /<path-to-config>/config.json:/var/foo.json \
flagsmith/edge-proxy:latest
```

## Load Testing

You can send post request with `wrk` like this:

```bash
cd load-test
wrk -t10 -c40 -d5 -s post.lua -H 'X-Environment-Key: <your environment key>' 'http://localhost:8001/api/v1/identities/?identifier=development_user_123456'
```

## Documentation

See [Edge Proxy documentation](https://docs.flagsmith.com/advanced-use/edge-proxy).
23 changes: 0 additions & 23 deletions config.json

This file was deleted.

4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ dependencies = [
]
requires-python = ">= 3.12"

[project.scripts]
edge-proxy-serve = 'edge_proxy.main:serve'
edge-proxy-config = 'edge_proxy.main:config'

[tool.rye]
managed = true
dev-dependencies = [
Expand Down
4 changes: 3 additions & 1 deletion src/edge_proxy/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ def __init__(
):
self.cache = cache or LocalMemEnvironmentsCache()
self.settings = settings or Settings()
self._client = client or httpx.AsyncClient(timeout=settings.api_poll_timeout)
self._client = client or httpx.AsyncClient(
timeout=settings.api_poll_timeout_seconds,
)
self.last_updated_at = None

if settings.endpoint_caches:
Expand Down
100 changes: 11 additions & 89 deletions src/edge_proxy/main.py
Original file line number Diff line number Diff line change
@@ -1,95 +1,17 @@
from contextlib import suppress
from datetime import datetime
import uvicorn

import httpx
import structlog
from fastapi import FastAPI, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import ORJSONResponse
from edge_proxy.settings import ensure_defaults, get_settings

from fastapi_utils.tasks import repeat_every

from edge_proxy.cache import LocalMemEnvironmentsCache
from edge_proxy.environments import EnvironmentService
from edge_proxy.exceptions import FeatureNotFoundError, FlagsmithUnknownKeyError
from edge_proxy.logging import setup_logging
from edge_proxy.models import IdentityWithTraits
from edge_proxy.settings import Settings

settings = Settings()
setup_logging(settings.logging)
environment_service = EnvironmentService(
LocalMemEnvironmentsCache(),
httpx.AsyncClient(timeout=settings.api_poll_timeout),
settings,
)
app = FastAPI()


@app.exception_handler(FlagsmithUnknownKeyError)
async def unknown_key_error(request, exc):
return ORJSONResponse(
status_code=401,
content={
"status": "unauthorized",
"message": f"unknown key {exc}",
},
def serve():
settings = get_settings()
uvicorn.run(
"edge_proxy.server:app",
host=str(settings.server.host),
port=settings.server.port,
reload=settings.server.reload,
)


@app.get("/health", response_class=ORJSONResponse, deprecated=True)
@app.get("/proxy/health", response_class=ORJSONResponse)
async def health_check():
with suppress(TypeError):
last_updated = datetime.now() - environment_service.last_updated_at
buffer = 30 * len(settings.environment_key_pairs) # 30s per environment
if last_updated.total_seconds() <= settings.api_poll_frequency + buffer:
return ORJSONResponse(status_code=200, content={"status": "ok"})

return ORJSONResponse(status_code=500, content={"status": "error"})


@app.get("/api/v1/flags/", response_class=ORJSONResponse)
async def flags(feature: str = None, x_environment_key: str = Header(None)):
try:
data = environment_service.get_flags_response_data(x_environment_key, feature)
except FeatureNotFoundError:
return ORJSONResponse(
status_code=404,
content={
"status": "not_found",
"message": f"feature '{feature}' not found",
},
)

return ORJSONResponse(data)


@app.post("/api/v1/identities/", response_class=ORJSONResponse)
async def identity(
input_data: IdentityWithTraits,
x_environment_key: str = Header(None),
):
data = environment_service.get_identity_response_data(input_data, x_environment_key)
return ORJSONResponse(data)


@app.on_event("startup")
@repeat_every(
seconds=settings.api_poll_frequency,
raise_exceptions=True,
logger=structlog.get_logger(__name__),
)
async def refresh_cache():
await environment_service.refresh_environment_caches()


app.add_middleware(
CORSMiddleware,
allow_origins=settings.allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
def config():
ensure_defaults()
95 changes: 95 additions & 0 deletions src/edge_proxy/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from contextlib import suppress
from datetime import datetime

import httpx
import structlog
from fastapi import FastAPI, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import ORJSONResponse

from fastapi_utils.tasks import repeat_every

from edge_proxy.cache import LocalMemEnvironmentsCache
from edge_proxy.environments import EnvironmentService
from edge_proxy.exceptions import FeatureNotFoundError, FlagsmithUnknownKeyError
from edge_proxy.logging import setup_logging
from edge_proxy.models import IdentityWithTraits
from edge_proxy.settings import get_settings

settings = get_settings()
setup_logging(settings.logging)
environment_service = EnvironmentService(
LocalMemEnvironmentsCache(),
httpx.AsyncClient(timeout=settings.api_poll_timeout_seconds),
settings,
)
app = FastAPI()


@app.exception_handler(FlagsmithUnknownKeyError)
async def unknown_key_error(request, exc):
return ORJSONResponse(
status_code=401,
content={
"status": "unauthorized",
"message": f"unknown key {exc}",
},
)


@app.get("/health", response_class=ORJSONResponse, deprecated=True)
@app.get("/proxy/health", response_class=ORJSONResponse)
async def health_check():
with suppress(TypeError):
last_updated = datetime.now() - environment_service.last_updated_at
buffer = 30 * len(settings.environment_key_pairs) # 30s per environment
if last_updated.total_seconds() <= settings.api_poll_frequency_seconds + buffer:
return ORJSONResponse(status_code=200, content={"status": "ok"})

return ORJSONResponse(status_code=500, content={"status": "error"})


@app.get("/api/v1/flags/", response_class=ORJSONResponse)
async def flags(feature: str = None, x_environment_key: str = Header(None)):
try:
data = environment_service.get_flags_response_data(x_environment_key, feature)
except FeatureNotFoundError:
return ORJSONResponse(
status_code=404,
content={
"status": "not_found",
"message": f"feature '{feature}' not found",
},
)

return ORJSONResponse(data)


@app.post("/api/v1/identities/", response_class=ORJSONResponse)
async def identity(
input_data: IdentityWithTraits,
x_environment_key: str = Header(None),
):
data = environment_service.get_identity_response_data(input_data, x_environment_key)
return ORJSONResponse(data)


@app.on_event("startup")
@repeat_every(
seconds=settings.api_poll_frequency_seconds,
raise_exceptions=True,
logger=structlog.get_logger(__name__),
)
async def refresh_cache():
await environment_service.refresh_environment_caches()


app.add_middleware(
CORSMiddleware,
allow_origins=settings.allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
Loading

0 comments on commit a5af729

Please sign in to comment.