Skip to content

Commit bbbba05

Browse files
authored
Rate limit implementation (stac-utils#303)
**Description:** Implementation of address based global rate limiting **option**. Rate limiting is an optional security feature that controls API request frequency on a remote address basis. It's enabled by setting the `STAC_FASTAPI_RATE_LIMIT` environment variable, e.g., `500/minute`. This limits each client to 500 requests per minute, helping prevent abuse and maintain API stability. Implementation examples are available in the [examples/rate_limit](examples/rate_limit) directory. **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog
1 parent 2d6cb4d commit bbbba05

File tree

11 files changed

+254
-3
lines changed

11 files changed

+254
-3
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Added
1111

1212
- Added `datetime_frequency_interval` parameter for `datetime_frequency` aggregation. [#294](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/294)
13+
- Added rate limiting functionality with configurable limits using environment variable `STAC_FASTAPI_RATE_LIMIT`, example: `500/minute`. [#303](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/303)
1314

1415
### Changed
1516

README.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -383,4 +383,8 @@ Available aggregations are:
383383
- geometry_geohash_grid_frequency ([geohash grid](https://opensearch.org/docs/latest/aggregations/bucket/geohash-grid/) on Item.geometry)
384384
- geometry_geotile_grid_frequency ([geotile grid](https://opensearch.org/docs/latest/aggregations/bucket/geotile-grid/) on Item.geometry)
385385

386-
Support for additional fields and new aggregations can be added in the associated `database_logic.py` file.
386+
Support for additional fields and new aggregations can be added in the associated `database_logic.py` file.
387+
388+
## Rate Limiting
389+
390+
Rate limiting is an optional security feature that controls API request frequency on a remote address basis. It's enabled by setting the `STAC_FASTAPI_RATE_LIMIT` environment variable, e.g., `500/minute`. This limits each client to 500 requests per minute, helping prevent abuse and maintain API stability. Implementation examples are available in the [examples/rate_limit](examples/rate_limit) directory.

docker-compose.yml

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ services:
5454
- ES_USE_SSL=false
5555
- ES_VERIFY_CERTS=false
5656
- BACKEND=opensearch
57+
- STAC_FASTAPI_RATE_LIMIT=200/minute
5758
ports:
5859
- "8082:8082"
5960
volumes:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
version: '3.9'
2+
3+
services:
4+
app-elasticsearch:
5+
container_name: stac-fastapi-es
6+
image: stac-utils/stac-fastapi-es
7+
restart: always
8+
build:
9+
context: .
10+
dockerfile: dockerfiles/Dockerfile.dev.es
11+
environment:
12+
- STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch
13+
- STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend
14+
- STAC_FASTAPI_VERSION=2.1
15+
- APP_HOST=0.0.0.0
16+
- APP_PORT=8080
17+
- RELOAD=true
18+
- ENVIRONMENT=local
19+
- WEB_CONCURRENCY=10
20+
- ES_HOST=elasticsearch
21+
- ES_PORT=9200
22+
- ES_USE_SSL=false
23+
- ES_VERIFY_CERTS=false
24+
- BACKEND=elasticsearch
25+
- STAC_FASTAPI_RATE_LIMIT=500/minute
26+
ports:
27+
- "8080:8080"
28+
volumes:
29+
- ./stac_fastapi:/app/stac_fastapi
30+
- ./scripts:/app/scripts
31+
- ./esdata:/usr/share/elasticsearch/data
32+
depends_on:
33+
- elasticsearch
34+
command:
35+
bash -c "./scripts/wait-for-it-es.sh es-container:9200 && python -m stac_fastapi.elasticsearch.app"
36+
37+
app-opensearch:
38+
container_name: stac-fastapi-os
39+
image: stac-utils/stac-fastapi-os
40+
restart: always
41+
build:
42+
context: .
43+
dockerfile: dockerfiles/Dockerfile.dev.os
44+
environment:
45+
- STAC_FASTAPI_TITLE=stac-fastapi-opensearch
46+
- STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend
47+
- STAC_FASTAPI_VERSION=3.0.0a2
48+
- APP_HOST=0.0.0.0
49+
- APP_PORT=8082
50+
- RELOAD=true
51+
- ENVIRONMENT=local
52+
- WEB_CONCURRENCY=10
53+
- ES_HOST=opensearch
54+
- ES_PORT=9202
55+
- ES_USE_SSL=false
56+
- ES_VERIFY_CERTS=false
57+
- BACKEND=opensearch
58+
- STAC_FASTAPI_RATE_LIMIT=200/minute
59+
ports:
60+
- "8082:8082"
61+
volumes:
62+
- ./stac_fastapi:/app/stac_fastapi
63+
- ./scripts:/app/scripts
64+
- ./osdata:/usr/share/opensearch/data
65+
depends_on:
66+
- opensearch
67+
command:
68+
bash -c "./scripts/wait-for-it-es.sh os-container:9202 && python -m stac_fastapi.opensearch.app"
69+
70+
elasticsearch:
71+
container_name: es-container
72+
image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0}
73+
hostname: elasticsearch
74+
environment:
75+
ES_JAVA_OPTS: -Xms512m -Xmx1g
76+
volumes:
77+
- ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
78+
- ./elasticsearch/snapshots:/usr/share/elasticsearch/snapshots
79+
ports:
80+
- "9200:9200"
81+
82+
opensearch:
83+
container_name: os-container
84+
image: opensearchproject/opensearch:${OPENSEARCH_VERSION:-2.11.1}
85+
hostname: opensearch
86+
environment:
87+
- discovery.type=single-node
88+
- plugins.security.disabled=true
89+
- OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m
90+
volumes:
91+
- ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml
92+
- ./opensearch/snapshots:/usr/share/opensearch/snapshots
93+
ports:
94+
- "9202:9202"

stac_fastapi/core/setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"pygeofilter==0.2.1",
2020
"typing_extensions==4.8.0",
2121
"jsonschema",
22+
"slowapi==0.1.9",
2223
]
2324

2425
setup(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Rate limiting middleware."""
2+
3+
import logging
4+
import os
5+
from typing import Optional
6+
7+
from fastapi import FastAPI, Request
8+
from slowapi import Limiter, _rate_limit_exceeded_handler
9+
from slowapi.errors import RateLimitExceeded
10+
from slowapi.middleware import SlowAPIMiddleware
11+
from slowapi.util import get_remote_address
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
def get_limiter(key_func=get_remote_address):
17+
"""Create and return a Limiter instance for rate limiting."""
18+
return Limiter(key_func=key_func)
19+
20+
21+
def setup_rate_limit(
22+
app: FastAPI, rate_limit: Optional[str] = None, key_func=get_remote_address
23+
):
24+
"""Set up rate limiting middleware."""
25+
RATE_LIMIT = rate_limit or os.getenv("STAC_FASTAPI_RATE_LIMIT")
26+
27+
if not RATE_LIMIT:
28+
logger.info("Rate limiting is disabled")
29+
return
30+
31+
logger.info(f"Setting up rate limit with RATE_LIMIT={RATE_LIMIT}")
32+
33+
limiter = get_limiter(key_func)
34+
app.state.limiter = limiter
35+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
36+
app.add_middleware(SlowAPIMiddleware)
37+
38+
@app.middleware("http")
39+
@limiter.limit(RATE_LIMIT)
40+
async def rate_limit_middleware(request: Request, call_next):
41+
response = await call_next(request)
42+
return response
43+
44+
logger.info("Rate limit setup complete")

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
EsAsyncAggregationClient,
1818
)
1919
from stac_fastapi.core.extensions.fields import FieldsExtension
20+
from stac_fastapi.core.rate_limit import setup_rate_limit
2021
from stac_fastapi.core.route_dependencies import get_route_dependencies
2122
from stac_fastapi.core.session import Session
2223
from stac_fastapi.elasticsearch.config import ElasticsearchSettings
@@ -97,6 +98,9 @@
9798
app = api.app
9899
app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "")
99100

101+
# Add rate limit
102+
setup_rate_limit(app, rate_limit=os.getenv("STAC_FASTAPI_RATE_LIMIT"))
103+
100104

101105
@app.on_event("startup")
102106
async def _startup_event() -> None:

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
EsAsyncAggregationClient,
1818
)
1919
from stac_fastapi.core.extensions.fields import FieldsExtension
20+
from stac_fastapi.core.rate_limit import setup_rate_limit
2021
from stac_fastapi.core.route_dependencies import get_route_dependencies
2122
from stac_fastapi.core.session import Session
2223
from stac_fastapi.extensions.core import (
@@ -97,6 +98,9 @@
9798
app = api.app
9899
app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "")
99100

101+
# Add rate limit
102+
setup_rate_limit(app, rate_limit=os.getenv("STAC_FASTAPI_RATE_LIMIT"))
103+
100104

101105
@app.on_event("startup")
102106
async def _startup_event() -> None:

stac_fastapi/tests/basic_auth/test_basic_auth.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ async def test_get_search_not_authenticated(app_client_basic_auth, ctx):
1818

1919
@pytest.mark.asyncio
2020
async def test_post_search_authenticated(app_client_basic_auth, ctx):
21-
"""Test protected endpoint [POST /search] with reader auhtentication"""
21+
"""Test protected endpoint [POST /search] with reader authentication"""
2222
if not os.getenv("BASIC_AUTH"):
2323
pytest.skip()
2424
params = {"id": ctx.item["id"]}
@@ -34,7 +34,7 @@ async def test_post_search_authenticated(app_client_basic_auth, ctx):
3434
async def test_delete_resource_anonymous(
3535
app_client_basic_auth,
3636
):
37-
"""Test protected endpoint [DELETE /collections/{collection_id}] without auhtentication"""
37+
"""Test protected endpoint [DELETE /collections/{collection_id}] without authentication"""
3838
if not os.getenv("BASIC_AUTH"):
3939
pytest.skip()
4040

stac_fastapi/tests/conftest.py

+60
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
EsAggregationExtensionPostRequest,
2525
EsAsyncAggregationClient,
2626
)
27+
from stac_fastapi.core.rate_limit import setup_rate_limit
2728
from stac_fastapi.core.route_dependencies import get_route_dependencies
2829

2930
if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch":
@@ -246,6 +247,65 @@ async def app_client(app):
246247
yield c
247248

248249

250+
@pytest_asyncio.fixture(scope="session")
251+
async def app_rate_limit():
252+
settings = AsyncSettings()
253+
254+
aggregation_extension = AggregationExtension(
255+
client=EsAsyncAggregationClient(
256+
database=database, session=None, settings=settings
257+
)
258+
)
259+
aggregation_extension.POST = EsAggregationExtensionPostRequest
260+
aggregation_extension.GET = EsAggregationExtensionGetRequest
261+
262+
search_extensions = [
263+
TransactionExtension(
264+
client=TransactionsClient(
265+
database=database, session=None, settings=settings
266+
),
267+
settings=settings,
268+
),
269+
SortExtension(),
270+
FieldsExtension(),
271+
QueryExtension(),
272+
TokenPaginationExtension(),
273+
FilterExtension(),
274+
FreeTextExtension(),
275+
]
276+
277+
extensions = [aggregation_extension] + search_extensions
278+
279+
post_request_model = create_post_request_model(search_extensions)
280+
281+
app = StacApi(
282+
settings=settings,
283+
client=CoreClient(
284+
database=database,
285+
session=None,
286+
extensions=extensions,
287+
post_request_model=post_request_model,
288+
),
289+
extensions=extensions,
290+
search_get_request_model=create_get_request_model(search_extensions),
291+
search_post_request_model=post_request_model,
292+
).app
293+
294+
# Set up rate limit
295+
setup_rate_limit(app, rate_limit="2/minute")
296+
297+
return app
298+
299+
300+
@pytest_asyncio.fixture(scope="session")
301+
async def app_client_rate_limit(app_rate_limit):
302+
await create_index_templates()
303+
await create_collection_index()
304+
305+
async with AsyncClient(app=app_rate_limit, base_url="http://test-server") as c:
306+
yield c
307+
308+
249309
@pytest_asyncio.fixture(scope="session")
250310
async def app_basic_auth():
251311

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import logging
2+
3+
import pytest
4+
from httpx import AsyncClient
5+
from slowapi.errors import RateLimitExceeded
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_rate_limit(app_client_rate_limit: AsyncClient, ctx):
12+
expected_status_codes = [200, 200, 429, 429, 429]
13+
14+
for i, expected_status_code in enumerate(expected_status_codes):
15+
try:
16+
response = await app_client_rate_limit.get("/collections")
17+
status_code = response.status_code
18+
except RateLimitExceeded:
19+
status_code = 429
20+
21+
logger.info(f"Request {i+1}: Status code {status_code}")
22+
assert (
23+
status_code == expected_status_code
24+
), f"Expected status code {expected_status_code}, but got {status_code}"
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_rate_limit_no_limit(app_client: AsyncClient, ctx):
29+
expected_status_codes = [200, 200, 200, 200, 200]
30+
31+
for i, expected_status_code in enumerate(expected_status_codes):
32+
response = await app_client.get("/collections")
33+
status_code = response.status_code
34+
35+
logger.info(f"Request {i+1}: Status code {status_code}")
36+
assert (
37+
status_code == expected_status_code
38+
), f"Expected status code {expected_status_code}, but got {status_code}"

0 commit comments

Comments
 (0)