diff --git a/CMakeLists.txt b/CMakeLists.txt index d2cc039..9f44dda 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,9 @@ if(BUILD_EXAMPLES) add_executable(todo_api examples/todo_api.cpp) target_link_libraries(todo_api PRIVATE cpphttp) + + add_executable(rate_limited_api examples/rate_limited_api.cpp) + target_link_libraries(rate_limited_api PRIVATE cpphttp) endif() # ── Tests (optional) ────────────────────────────────────────────────────────── diff --git a/examples/rate_limited_api.cpp b/examples/rate_limited_api.cpp new file mode 100644 index 0000000..8c7c700 --- /dev/null +++ b/examples/rate_limited_api.cpp @@ -0,0 +1,33 @@ +#include +#include +#include +#include +#include + +using namespace cpphttp; + +Server* g_server = nullptr; + +void signal_handler(int) { + std::cout << "\nShutting down...\n"; + if (g_server) g_server->stop(); +} + +int main() { + std::signal(SIGINT, signal_handler); + + Server app; + g_server = &app; + + // 5 requests per 10 seconds, per client IP. + static middleware::RateLimiter limiter(5, std::chrono::seconds(10)); + app.use(limiter.handler()); + + app.get("/api/ping", [](const Request& /*req*/, Response& res) { + res.json(R"({"message":"pong"})"); + }); + + // Try hammering this route more than 5 times in 10s — you'll get a 429. + std::cout << "Rate-limited server on :8080 (5 req / 10s per IP)\n"; + app.listen(8080); +} diff --git a/include/cpphttp/middleware/rate_limiter.hpp b/include/cpphttp/middleware/rate_limiter.hpp new file mode 100644 index 0000000..6b62191 --- /dev/null +++ b/include/cpphttp/middleware/rate_limiter.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "../request.hpp" +#include "../response.hpp" + +namespace cpphttp::middleware { + +/** + * @brief Per-client token-bucket rate limiter. + * + * Usage: + * cpphttp::middleware::RateLimiter limiter(100, std::chrono::seconds(60)); + * app.use(limiter.handler()); + * + * Buckets are keyed by Request::remote_addr. Each bucket refills + * continuously at max_requests / window, capped at max_requests. + * Thread-safe: guarded by a single mutex, sized for thread-per-connection + * servers with moderate concurrency. + */ +class RateLimiter { +public: + RateLimiter(std::size_t max_requests, std::chrono::steady_clock::duration window) + : max_requests_(max_requests), window_(window) {} + + using Handler = std::function; + + Handler handler() { + return [this](const Request& req, Response& res) { this->check(req, res); }; + } + + void check(const Request& req, Response& res) { + const auto& key = req.remote_addr; + const auto now = std::chrono::steady_clock::now(); + + std::lock_guard lock(mutex_); + auto it = buckets_.find(key); + if (it == buckets_.end()) { + it = buckets_.emplace(key, Bucket{static_cast(max_requests_), now}).first; + } + auto& bucket = it->second; + + if (bucket.tokens < static_cast(max_requests_)) { + const double elapsed_s = + std::chrono::duration(now - bucket.last_refill).count(); + const double refill_rate = + static_cast(max_requests_) / + std::chrono::duration(window_).count(); + bucket.tokens = + std::min(static_cast(max_requests_), + bucket.tokens + elapsed_s * refill_rate); + } + bucket.last_refill = now; + + if (bucket.tokens < 1.0) { + const double refill_rate = + static_cast(max_requests_) / + std::chrono::duration(window_).count(); + const int retry_after_s = + static_cast(std::ceil((1.0 - bucket.tokens) / refill_rate)); + res.set("Retry-After", std::to_string(retry_after_s)) + .status(429) + .json(R"({"error":"Too Many Requests"})"); + return; + } + + bucket.tokens -= 1.0; + } + +private: + struct Bucket { + double tokens = 0.0; + std::chrono::steady_clock::time_point last_refill = std::chrono::steady_clock::now(); + }; + + std::size_t max_requests_; + std::chrono::steady_clock::duration window_; + std::unordered_map buckets_; + std::mutex mutex_; +}; + +} // namespace cpphttp::middleware