From 4609026efe32e5d324095e02e563fd6e0f634607 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Tue, 10 Mar 2026 12:46:51 +0000 Subject: [PATCH 1/5] Implement ERL --- examples/erl.cpp | 28 ++++++++++++ include/fastly/erl.h | 101 +++++++++++++++++++++++++++++++++++++++++++ src/cpp/erl.cpp | 83 +++++++++++++++++++++++++++++++++++ src/cpp/fastly.h | 71 ++++++++++++++++++++++++++++++ test/erl.cpp | 44 +++++++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 examples/erl.cpp create mode 100644 include/fastly/erl.h create mode 100644 src/cpp/erl.cpp create mode 100644 src/cpp/fastly.h create mode 100644 test/erl.cpp diff --git a/examples/erl.cpp b/examples/erl.cpp new file mode 100644 index 0000000..de62005 --- /dev/null +++ b/examples/erl.cpp @@ -0,0 +1,28 @@ +//! @example erl.cpp +#include "fastly/sdk.h" + +// Note that this example will return "Welcome!" unconditionally on Viceroy; to see +// the expected behavior, you need to run it on a Compute service. + +int main() { + auto req{fastly::Request::from_client()}; + auto client = req.get_client_ip_addr().value(); + + fastly::erl::RateCounter rate_counter("mycounter"); + fastly::erl::PenaltyBox penalty_box("mypenaltybox"); + + auto erl = fastly::erl::ERL(rate_counter, penalty_box); + auto check_result = erl.check_rate( + client, // Use the client IP address as the entry to check in the rate counter + 1, // How many requests to count this as + fastly::erl::RateWindow::SixtySecs, // The window to check the rate over + 5, // The maximum allowed rate for the client over the window + std::chrono::minutes(1)); // The duration to penalize the client if they exceed the rate + if (check_result.has_value() && *check_result) { + fastly::Response::from_body("You are blocked!").send_to_client(); + } else { + std::string message = "Welcome! Your current rate is "; + message += std::to_string(rate_counter.lookup_rate(client, fastly::erl::RateWindow::SixtySecs).value()); + fastly::Response::from_body(message).send_to_client(); + } +} \ No newline at end of file diff --git a/include/fastly/erl.h b/include/fastly/erl.h new file mode 100644 index 0000000..c24ca18 --- /dev/null +++ b/include/fastly/erl.h @@ -0,0 +1,101 @@ +#ifndef FASTLY_ERL_H +#define FASTLY_ERL_H + +#include +#include + +namespace fastly::erl { +/// Errors that can arise during ERL operations. +class ERLError { +public: + enum Code { + Unexpected, + Unknown, + InvalidArgument, + }; + explicit ERLError(Code code) : code_(code) {} + Code code() const { return code_; } + +private: + Code code_; +}; + +/// A penalty box that can be used with the edge rate limiter or stand alone for +/// adding and checking if some entry is in the data set. +class PenaltyBox { +public: + explicit PenaltyBox(std::string name) : name_(std::move(name)) {}; + /// Add entry to a the penaltybox for the duration of ttl. Valid ttl span is + /// 1m to 1h. + tl::expected add(std::string_view entry, + std::chrono::minutes ttl); + /// Check if entry is in the penaltybox. + tl::expected has(std::string_view entry) const; + std::string_view name() const { return name_; } + +private: + std::string name_; +}; + +/// To be used for picking the duration in a rate counter `lookup_count` call +enum class CounterDuration { + TenSec = 10, + TwentySecs = 20, + ThirtySecs = 30, + FortySecs = 40, + FiftySecs = 50, + SixtySecs = 60 +}; + +/// To be used for picking the window in a rate counter `lookup_rate` or a ERL +/// `check_rate` call. +enum class RateWindow { + OneSec = 1, + TenSecs = 10, + SixtySecs = 60, +}; + +/// A rate counter that can be used with an edge rate limiter or stand alone for +/// counting and rate calculations +class RateCounter { +public: + explicit RateCounter(std::string name) : name_(std::move(name)) {} + /// Increment an entry in the ratecounter by delta. + tl::expected increment(std::string_view entry, + std::uint32_t delta); + /// Lookup the current rate for entry in the rate counter for a window. + tl::expected lookup_rate(std::string_view entry, + RateWindow window) const; + /// Lookup the current count for entry in the rate counter for a duration. + tl::expected lookup_count(std::string_view entry, + CounterDuration duration) const; + std::string_view name() const { return name_; } + +private: + std::string name_; +}; + +class ERL { +public: + ERL(RateCounter rate_counter, PenaltyBox penalty_box) + : rate_counter_(std::move(rate_counter)), + penalty_box_(std::move(penalty_box)) {} + + /// Increment an entry in a rate counter and check if the client has exceeded + /// some average number + /// of requests per second (RPS) over the window. If the client is over the rps + /// limit for the window, add to the penaltybox for ttl. Valid ttl span is 1m + /// to 1h. + tl::expected + check_rate(std::string_view entry, std::uint32_t delta, RateWindow window, + std::uint32_t limit, std::chrono::minutes ttl) const; + + const RateCounter &rate_counter() const { return rate_counter_; } + const PenaltyBox &penalty_box() const { return penalty_box_; } + +private: + RateCounter rate_counter_; + PenaltyBox penalty_box_; +}; +} // namespace fastly::erl +#endif \ No newline at end of file diff --git a/src/cpp/erl.cpp b/src/cpp/erl.cpp new file mode 100644 index 0000000..66f3826 --- /dev/null +++ b/src/cpp/erl.cpp @@ -0,0 +1,83 @@ +#include "fastly.h" +#include +#include + +namespace { +fastly::erl::ERLError from_status(const fastly::Status &status) { + using Code = fastly::Status::Code; + switch (status.code()) { + case Code::InvalidArgument: + return fastly::erl::ERLError(fastly::erl::ERLError::Code::InvalidArgument); + case Code::GenericError: + return fastly::erl::ERLError(fastly::erl::ERLError::Code::Unexpected); + default: + return fastly::erl::ERLError(fastly::erl::ERLError::Code::Unknown); + } +} +} // namespace + +#define ERL_TRY(expr) \ + do { \ + auto status = (expr); \ + if (!status.is_ok()) { \ + return tl::unexpected(from_status(status)); \ + } \ + } while (0) + +namespace fastly::erl { +tl::expected PenaltyBox::add(std::string_view entry, + std::chrono::minutes ttl) { + // The host expects the TTL in seconds, even though it's truncated to minutes. + std::chrono::seconds ttl_seconds = + std::chrono::duration_cast(ttl); + ERL_TRY(fastly::penaltybox_add(name_.c_str(), name_.size(), entry.data(), + entry.size(), ttl_seconds.count())); + return {}; +} +tl::expected PenaltyBox::has(std::string_view entry) const { + alignas(4) bool has_out; + ERL_TRY(fastly::penaltybox_has(name_.c_str(), name_.size(), entry.data(), + entry.size(), &has_out)); + return has_out; +} + +tl::expected RateCounter::increment(std::string_view entry, + std::uint32_t delta) { + ERL_TRY(fastly::ratecounter_increment(name_.c_str(), name_.size(), + entry.data(), entry.size(), delta)); + return {}; +} + +tl::expected +RateCounter::lookup_rate(std::string_view entry, RateWindow window) const { + std::uint32_t rate_out; + ERL_TRY(fastly::ratecounter_lookup_rate( + name_.c_str(), name_.size(), entry.data(), entry.size(), + static_cast(window), &rate_out)); + return rate_out; +} + +tl::expected +RateCounter::lookup_count(std::string_view entry, CounterDuration duration) const { + std::uint32_t count_out; + ERL_TRY(fastly::ratecounter_lookup_count( + name_.c_str(), name_.size(), entry.data(), entry.size(), + static_cast(duration), &count_out)); + return count_out; +} + +tl::expected +ERL::check_rate(std::string_view entry, std::uint32_t delta, RateWindow window, + std::uint32_t limit, std::chrono::minutes ttl) const { + // The host expects the TTL in seconds, even though it's truncated to minutes. + std::chrono::seconds ttl_seconds = + std::chrono::duration_cast(ttl); + alignas(4) bool blocked_out; + ERL_TRY(fastly::check_rate( + rate_counter_.name().data(), rate_counter_.name().size(), entry.data(), + entry.size(), delta, static_cast(window), limit, + penalty_box_.name().data(), penalty_box_.name().size(), + static_cast(ttl_seconds.count()), &blocked_out)); + return blocked_out; +} +} // namespace fastly::erl \ No newline at end of file diff --git a/src/cpp/fastly.h b/src/cpp/fastly.h new file mode 100644 index 0000000..cc9ba21 --- /dev/null +++ b/src/cpp/fastly.h @@ -0,0 +1,71 @@ +#ifndef FASTLY_H +#define FASTLY_H + +#include +#include +#include + +#define WASM_IMPORT(module, name) \ + __attribute__((import_module(module), import_name(name))) + +namespace fastly { +class Status { +public: + enum Code : std::uint32_t { + Ok = 0, + GenericError = 1, + InvalidArgument = 2, + BadHandle = 3, + BufferLen = 4, + Unsupported = 5, + BadAlign = 6, + HttpInvalid = 7, + HttpUser = 8, + HttpIncomplete = 9, + OptionalNone = 10, + HttpHeadTooLarge = 11, + HttpInvalidStatus = 12, + LimitExceeded = 13, + }; + bool is_ok() const { return code_ == Ok; } + explicit operator bool() const { return is_ok(); } + Code code() const { return code_; } + Status() = default; + Status(Code code) : code_(code) {} + Status(const Status &) = default; + Status &operator=(const Status &) = default; + +private: + Code code_; +}; +static_assert(std::is_trivial_v, "Status must be trivial"); + +WASM_IMPORT("fastly_erl", "check_rate") +Status check_rate(const char *rc, size_t rc_len, const char *entry, + size_t entry_len, uint32_t delta, uint32_t window, + uint32_t limit, const char *pb, size_t pb_len, uint32_t ttl, + bool *blocked_out); + +WASM_IMPORT("fastly_erl", "ratecounter_increment") +Status ratecounter_increment(const char *rc, size_t rc_len, const char *entry, + size_t entry_len, uint32_t delta); + +WASM_IMPORT("fastly_erl", "ratecounter_lookup_rate") +Status ratecounter_lookup_rate(const char *rc, size_t rc_len, const char *entry, + size_t entry_len, uint32_t window, + uint32_t *rate_out); + +WASM_IMPORT("fastly_erl", "ratecounter_lookup_count") +Status ratecounter_lookup_count(const char *rc, size_t rc_len, + const char *entry, size_t entry_len, + uint32_t duration, uint32_t *count_out); + +WASM_IMPORT("fastly_erl", "penaltybox_add") +Status penaltybox_add(const char *pb, size_t pb_len, const char *entry, + size_t entry_len, uint32_t ttl); + +WASM_IMPORT("fastly_erl", "penaltybox_has") +Status penaltybox_has(const char *pb, size_t pb_len, const char *entry, + size_t entry_len, bool *has_out); +} // namespace fastly +#endif // FASTLY_H \ No newline at end of file diff --git a/test/erl.cpp b/test/erl.cpp new file mode 100644 index 0000000..26dde83 --- /dev/null +++ b/test/erl.cpp @@ -0,0 +1,44 @@ +#include +#include + +using namespace fastly::erl; + +// The Viceroy implementation of ERL is stubbed, so this just ensures that +// the C++ wrapper functions are correctly calling into the host and handling +// the results. + +TEST_CASE("PenaltyBox add and has") { + PenaltyBox box("testbox"); + auto result = box.add("bad_entry", std::chrono::minutes(5)); + REQUIRE(result.has_value()); + + auto has_result = box.has("good_entry"); + REQUIRE(has_result.has_value()); + REQUIRE(!*has_result); +} + +TEST_CASE("RateCounter increment, lookup_rate, and lookup_count") { + RateCounter counter("testcounter"); + auto result = counter.increment("entry1", 1); + REQUIRE(result.has_value()); + + auto rate_result = counter.lookup_rate("entry1", RateWindow::OneSec); + REQUIRE(rate_result.has_value()); + + auto count_result = counter.lookup_count("entry1", CounterDuration::TenSec); + REQUIRE(count_result.has_value()); +} + +TEST_CASE("ERL check_rate") { + ERL erl(RateCounter("testcounter"), PenaltyBox("testbox")); + auto result = erl.check_rate("entry1", 1, RateWindow::OneSec, 5, + std::chrono::minutes(5)); + REQUIRE(result.has_value()); + + auto has_result = erl.penalty_box().has("entry1"); + REQUIRE(has_result.has_value()); +} + +// Required due to https://github.com/WebAssembly/wasi-libc/issues/485 +#include +int main(int argc, char *argv[]) { return Catch::Session().run(argc, argv); } \ No newline at end of file From e1c8b9d0342a4dd0a7783156de4e0348ab6599a8 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Tue, 10 Mar 2026 12:49:29 +0000 Subject: [PATCH 2/5] fmt --- examples/erl.cpp | 44 ++++++++++++++++++++++++-------------------- include/fastly/erl.h | 10 +++++----- src/cpp/erl.cpp | 5 +++-- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/examples/erl.cpp b/examples/erl.cpp index de62005..3f774a2 100644 --- a/examples/erl.cpp +++ b/examples/erl.cpp @@ -1,28 +1,32 @@ //! @example erl.cpp #include "fastly/sdk.h" -// Note that this example will return "Welcome!" unconditionally on Viceroy; to see -// the expected behavior, you need to run it on a Compute service. +// Note that this example will return "Welcome!" unconditionally on Viceroy; to +// see the expected behavior, you need to run it on a Compute service. int main() { - auto req{fastly::Request::from_client()}; - auto client = req.get_client_ip_addr().value(); + auto req{fastly::Request::from_client()}; + auto client = req.get_client_ip_addr().value(); - fastly::erl::RateCounter rate_counter("mycounter"); - fastly::erl::PenaltyBox penalty_box("mypenaltybox"); + fastly::erl::RateCounter rate_counter("mycounter"); + fastly::erl::PenaltyBox penalty_box("mypenaltybox"); - auto erl = fastly::erl::ERL(rate_counter, penalty_box); - auto check_result = erl.check_rate( - client, // Use the client IP address as the entry to check in the rate counter - 1, // How many requests to count this as - fastly::erl::RateWindow::SixtySecs, // The window to check the rate over - 5, // The maximum allowed rate for the client over the window - std::chrono::minutes(1)); // The duration to penalize the client if they exceed the rate - if (check_result.has_value() && *check_result) { - fastly::Response::from_body("You are blocked!").send_to_client(); - } else { - std::string message = "Welcome! Your current rate is "; - message += std::to_string(rate_counter.lookup_rate(client, fastly::erl::RateWindow::SixtySecs).value()); - fastly::Response::from_body(message).send_to_client(); - } + auto erl = fastly::erl::ERL(rate_counter, penalty_box); + auto check_result = erl.check_rate( + client, // Use the client IP address as the entry to check in the rate + // counter + 1, // How many requests to count this as + fastly::erl::RateWindow::SixtySecs, // The window to check the rate over + 5, // The maximum allowed rate for the client over the window + std::chrono::minutes( + 1)); // The duration to penalize the client if they exceed the rate + if (check_result.has_value() && *check_result) { + fastly::Response::from_body("You are blocked!").send_to_client(); + } else { + std::string message = "Welcome! Your current rate is "; + message += std::to_string( + rate_counter.lookup_rate(client, fastly::erl::RateWindow::SixtySecs) + .value()); + fastly::Response::from_body(message).send_to_client(); + } } \ No newline at end of file diff --git a/include/fastly/erl.h b/include/fastly/erl.h index c24ca18..c24f2c8 100644 --- a/include/fastly/erl.h +++ b/include/fastly/erl.h @@ -67,8 +67,8 @@ class RateCounter { tl::expected lookup_rate(std::string_view entry, RateWindow window) const; /// Lookup the current count for entry in the rate counter for a duration. - tl::expected lookup_count(std::string_view entry, - CounterDuration duration) const; + tl::expected + lookup_count(std::string_view entry, CounterDuration duration) const; std::string_view name() const { return name_; } private: @@ -83,9 +83,9 @@ class ERL { /// Increment an entry in a rate counter and check if the client has exceeded /// some average number - /// of requests per second (RPS) over the window. If the client is over the rps - /// limit for the window, add to the penaltybox for ttl. Valid ttl span is 1m - /// to 1h. + /// of requests per second (RPS) over the window. If the client is over the + /// rps limit for the window, add to the penaltybox for ttl. Valid ttl span is + /// 1m to 1h. tl::expected check_rate(std::string_view entry, std::uint32_t delta, RateWindow window, std::uint32_t limit, std::chrono::minutes ttl) const; diff --git a/src/cpp/erl.cpp b/src/cpp/erl.cpp index 66f3826..e6c19cb 100644 --- a/src/cpp/erl.cpp +++ b/src/cpp/erl.cpp @@ -35,7 +35,7 @@ tl::expected PenaltyBox::add(std::string_view entry, return {}; } tl::expected PenaltyBox::has(std::string_view entry) const { - alignas(4) bool has_out; + alignas(4) bool has_out; ERL_TRY(fastly::penaltybox_has(name_.c_str(), name_.size(), entry.data(), entry.size(), &has_out)); return has_out; @@ -58,7 +58,8 @@ RateCounter::lookup_rate(std::string_view entry, RateWindow window) const { } tl::expected -RateCounter::lookup_count(std::string_view entry, CounterDuration duration) const { +RateCounter::lookup_count(std::string_view entry, + CounterDuration duration) const { std::uint32_t count_out; ERL_TRY(fastly::ratecounter_lookup_count( name_.c_str(), name_.size(), entry.data(), entry.size(), From 7143f90b45fa4350a6587a1971d14bd426eba02f Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Tue, 10 Mar 2026 12:54:44 +0000 Subject: [PATCH 3/5] Bump toolchain --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e8594ed..70c4c62 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.86.0" +channel = "1.88.0" targets = [ "wasm32-wasip1" ] \ No newline at end of file From b556125cedf0910345f2df113aa37d5499a3a9b8 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Tue, 10 Mar 2026 12:59:34 +0000 Subject: [PATCH 4/5] Fix clippy warning --- src/http/request.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/http/request.rs b/src/http/request.rs index 143cb58..032f56f 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -43,6 +43,7 @@ pub mod request { } } + #[allow(clippy::large_enum_variant)] pub enum PollResult { Pending(PendingRequest), Response(Response), From 75dd43df3738d6e0002870699e6bc24862197c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kat=20March=C3=A1n?= Date: Tue, 10 Mar 2026 12:03:02 -0700 Subject: [PATCH 5/5] deal with ci failure --- .github/workflows/ci.yml | 11 ++++++++--- Cargo.lock | 8 ++++---- Cargo.toml | 4 ++-- rust-toolchain.toml | 4 +++- src/lib.rs | 1 + 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f9825d..cee43c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Install WASI SDK uses: konsumer/install-wasi-sdk@v1 with: - version: "25" + version: "30" - name: Run clippy run: cargo clippy --all -- -D warnings @@ -49,7 +49,7 @@ jobs: - name: Install WASI SDK uses: konsumer/install-wasi-sdk@v1 with: - version: "25" + version: "30" - name: Check C++ Format run: /opt/wasi-sdk/bin/clang-format --dry-run --Werror include/**/*.h src/**/*.h src/**/*.cpp @@ -63,17 +63,22 @@ jobs: uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - name: Install Viceroy run: cargo install viceroy --locked - name: Install WASI SDK uses: konsumer/install-wasi-sdk@v1 with: - version: "25" + version: "30" - uses: extractions/setup-just@v3 - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: wasm32-wasip1 - name: Build WebAssembly module run: just diff --git a/Cargo.lock b/Cargo.lock index 85e2f35..0b84006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -523,9 +523,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "link-cplusplus" -version = "1.0.10" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" dependencies = [ "cc", ] @@ -656,9 +656,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" diff --git a/Cargo.toml b/Cargo.toml index 00e5bc9..5acd0f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Kat Marchán ", "Sy Brand