feat: add flexible askai rate limiter

This commit is contained in:
shenlan 2025-08-04 00:00:49 +08:00
parent cdd66a5be6
commit 8a4cb649e3
4 changed files with 182 additions and 54 deletions

View File

@ -0,0 +1,46 @@
name: Build and Release Askai Limiter
on:
workflow_dispatch:
push:
paths:
- 'wasm/askai_limiter/**'
- '.github/workflows/build-askai-limiter.yml'
- 'Makefile'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: wasm32-wasip1
profile: minimal
override: true
- name: Build Wasm Module
run: make wasm-askai-limiter
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: askai_limiter.wasm
path: build/askai_limiter.wasm
release:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: askai_limiter.wasm
path: release
- name: Publish GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: askai-limiter-${{ github.run_number }}
name: Askai Limiter ${{ github.run_number }}
files: release/askai_limiter.wasm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -2,72 +2,82 @@
GO=go
APP_NAME=xcontrol
WASM_TARGET=wasm32-wasip1
WASM_MODULE=askai_limiter
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
.PHONY: all build agent vet test clean release \
macos-x64 macos-arm64 windows-x64 linux-x64 linux-arm64 macos linux
macos-x64 macos-arm64 windows-x64 linux-x64 linux-arm64 macos linux \
wasm-askai-limiter
all: vet test build
build:
$(GO) build -o bin/$(APP_NAME) ./cmd/api
$(GO) build -o bin/$(APP_NAME) ./cmd/api
agent:
$(GO) build -o bin/$(APP_NAME)-agent ./cmd/agent
$(GO) build -o bin/$(APP_NAME)-agent ./cmd/agent
vet:
$(GO) vet ./...
$(GO) vet ./...
test:
$(GO) test ./...
$(GO) test ./...
clean:
rm -rf bin build
rm -rf bin build wasm/$(WASM_MODULE)/target
wasm-askai-limiter:
rustup target add $(WASM_TARGET)
cargo build --release --target $(WASM_TARGET) --manifest-path wasm/$(WASM_MODULE)/Cargo.toml
mkdir -p build
cp wasm/$(WASM_MODULE)/target/$(WASM_TARGET)/release/$(WASM_MODULE).wasm build/$(WASM_MODULE).wasm
macos-x64:
@if [ "$(UNAME_S)" = "Darwin" ] && [ "$(UNAME_M)" = "x86_64" ]; then \
GOOS=darwin GOARCH=amd64 $(GO) build -o build/macos-x64/$(APP_NAME) ./cmd/api; \
else \
echo "macos-x64 build requires macOS x86_64"; \
fi
@if [ "$(UNAME_S)" = "Darwin" ] && [ "$(UNAME_M)" = "x86_64" ]; then \
GOOS=darwin GOARCH=amd64 $(GO) build -o build/macos-x64/$(APP_NAME) ./cmd/api; \
else \
echo "macos-x64 build requires macOS x86_64"; \
fi
macos-arm64:
@if [ "$(UNAME_S)" = "Darwin" ] && [ "$(UNAME_M)" = "arm64" ]; then \
GOOS=darwin GOARCH=arm64 $(GO) build -o build/macos-arm64/$(APP_NAME) ./cmd/api; \
else \
echo "macos-arm64 build requires macOS arm64"; \
fi
@if [ "$(UNAME_S)" = "Darwin" ] && [ "$(UNAME_M)" = "arm64" ]; then \
GOOS=darwin GOARCH=arm64 $(GO) build -o build/macos-arm64/$(APP_NAME) ./cmd/api; \
else \
echo "macos-arm64 build requires macOS arm64"; \
fi
windows-x64:
GOOS=windows GOARCH=amd64 $(GO) build -o build/windows-x64/$(APP_NAME).exe ./cmd/api
GOOS=windows GOARCH=amd64 $(GO) build -o build/windows-x64/$(APP_NAME).exe ./cmd/api
linux-x64:
GOOS=linux GOARCH=amd64 $(GO) build -o build/linux-x64/$(APP_NAME) ./cmd/api
GOOS=linux GOARCH=amd64 $(GO) build -o build/linux-x64/$(APP_NAME) ./cmd/api
linux-arm64:
GOOS=linux GOARCH=arm64 $(GO) build -o build/linux-arm64/$(APP_NAME) ./cmd/api
GOOS=linux GOARCH=arm64 $(GO) build -o build/linux-arm64/$(APP_NAME) ./cmd/api
release: macos-x64 macos-arm64 windows-x64 linux-x64 linux-arm64
release: macos-x64 macos-arm64 windows-x64 linux-x64 linux-arm64 wasm-askai-limiter
macos:
@if [ "$(UNAME_S)" = "Darwin" ]; then \
if [ "$(UNAME_M)" = "arm64" ]; then \
GOOS=darwin GOARCH=arm64 $(GO) build -o build/macos-arm64/$(APP_NAME) ./cmd/api; \
else \
GOOS=darwin GOARCH=amd64 $(GO) build -o build/macos-x64/$(APP_NAME) ./cmd/api; \
fi; \
else \
echo "macos build requires macOS host"; \
fi
@if [ "$(UNAME_S)" = "Darwin" ]; then \
if [ "$(UNAME_M)" = "arm64" ]; then \
GOOS=darwin GOARCH=arm64 $(GO) build -o build/macos-arm64/$(APP_NAME) ./cmd/api; \
else \
GOOS=darwin GOARCH=amd64 $(GO) build -o build/macos-x64/$(APP_NAME) ./cmd/api; \
fi; \
else \
echo "macos build requires macOS host"; \
fi
linux:
@if [ "$(UNAME_S)" = "Linux" ]; then \
if [ "$(UNAME_M)" = "aarch64" ] || [ "$(UNAME_M)" = "arm64" ]; then \
GOOS=linux GOARCH=arm64 $(GO) build -o build/linux-arm64/$(APP_NAME) ./cmd/api; \
else \
GOOS=linux GOARCH=amd64 $(GO) build -o build/linux-x64/$(APP_NAME) ./cmd/api; \
fi; \
else \
echo "linux build requires Linux host"; \
@if [ "$(UNAME_S)" = "Linux" ]; then \
if [ "$(UNAME_M)" = "aarch64" ] || [ "$(UNAME_M)" = "arm64" ]; then \
GOOS=linux GOARCH=arm64 $(GO) build -o build/linux-arm64/$(APP_NAME) ./cmd/api; \
else \
GOOS=linux GOARCH=amd64 $(GO) build -o build/linux-x64/$(APP_NAME) ./cmd/api; \
fi; \
else \
echo "linux build requires Linux host"; \
fi

View File

@ -2,7 +2,28 @@
This module provides a simple API rate limiter for Nginx using the experimental
`ngx_http_wasm_module` and the [proxy-wasm-rust-sdk](https://github.com/proxy-wasm/proxy-wasm-rust-sdk).
It enforces a global daily limit of **200** requests per API endpoint.
It supports two counting modes:
- **Rolling** requests are counted within a sliding time window. The default
configuration limits to **200** requests every **24** hours.
- **Unlimited** a fixed quota that never resets until the limit is exhausted.
### Configuration
The module accepts a simple commaseparated configuration string when loaded:
```
limit=200,window=86400
```
- `limit` maximum allowed requests.
- `window` (optional) duration of the rolling window in seconds. Omit this
field to enable the unlimited mode.
Examples:
- `limit=1000` unlimited counting with a quota of 1000 total requests.
- `limit=1400,window=604800` 1400 requests allowed every 7 days (168 hours).
## Build
@ -46,5 +67,5 @@ http {
}
```
Requests beyond the first 200 in a single day will return HTTP 429 with the
body `{"error":"API daily limit reached"}`.
Requests exceeding the configured quota will return HTTP 429 with the body
`{"error":"API limit reached"}`.

View File

@ -2,53 +2,104 @@ use proxy_wasm::traits::*;
use proxy_wasm::types::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct AskaiLimiter;
#[derive(Clone, Copy)]
struct Config {
limit: u32,
window: Option<u64>, // seconds; None means unlimited
}
impl Default for Config {
fn default() -> Self {
Self {
limit: 200,
window: Some(86_400),
}
}
}
struct AskaiLimiter {
config: Config,
}
struct AskaiLimiterRoot {
config: Config,
}
impl Context for AskaiLimiter {}
impl HttpContext for AskaiLimiter {
fn on_http_request_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action {
// Use the day since UNIX epoch as the key
let today = SystemTime::now()
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
/ 86_400;
let key = format!("askai:{}", today);
.as_secs();
let key = match self.config.window {
Some(window) => format!("askai:{}", now / window),
None => "askai:total".to_string(),
};
// Read the current count from shared data
let (data, _cas) = self.get_shared_data(&key);
let mut count = data
.and_then(|d| String::from_utf8(d).ok())
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
if count >= 200 {
if count >= self.config.limit {
self.send_http_response(
429,
vec![("Content-Type", "application/json")],
Some(b"{\"error\":\"API daily limit reached\"}"),
Some(b"{\"error\":\"API limit reached\"}"),
);
return Action::Pause;
}
// Increment and store the updated count
count += 1;
let _ = self.set_shared_data(&key, Some(count.to_string().as_bytes()), None);
Action::Continue
}
}
impl RootContext for AskaiLimiter {
fn on_configure(&mut self, _configuration_size: usize) -> bool {
impl Context for AskaiLimiterRoot {}
impl RootContext for AskaiLimiterRoot {
fn on_configure(&mut self, _size: usize) -> bool {
if let Some(config_bytes) = self.get_plugin_configuration() {
if let Ok(text) = String::from_utf8(config_bytes) {
self.config = parse_config(&text, self.config);
}
}
true
}
fn create_http_context(&self, _context_id: u32) -> Option<Box<dyn HttpContext>> {
Some(Box::new(AskaiLimiter))
Some(Box::new(AskaiLimiter { config: self.config }))
}
}
fn parse_config(text: &str, mut cfg: Config) -> Config {
for part in text.split(',') {
let mut kv = part.splitn(2, '=');
let key = kv.next().unwrap_or("").trim();
let val = kv.next().unwrap_or("").trim();
match key {
"limit" => {
if let Ok(v) = val.parse::<u32>() {
cfg.limit = v;
}
}
"window" | "period" => {
if let Ok(v) = val.parse::<u64>() {
cfg.window = Some(v);
}
}
_ => {}
}
}
cfg
}
proxy_wasm::main! {{
proxy_wasm::set_http_context(|_, _| Box::new(AskaiLimiter));
proxy_wasm::set_root_context(|_| Box::new(AskaiLimiterRoot {
config: Config::default(),
}));
}}