Skip to content

Conversation

@thorsten
Copy link
Owner

@thorsten thorsten commented Feb 9, 2026

Summary by CodeRabbit

  • New Features

    • API rate limiting with standard rate-limit headers (limit, remaining, reset, Retry-After).
  • Database

    • New rate-limits table to track per-key windows and request counts.
  • Configuration

    • Default rate-limit settings added (requests and interval).
  • Services

    • Rate limiter registered as an application service.
  • Chore

    • Removed helper to drop all tables.
  • Tests

    • Added rate-limiter and migration tests; updated schema/table-count assertions.

@coderabbitai
Copy link

coderabbitai bot commented Feb 9, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds a DB-backed API rate limiter with a new RateLimiter class, schema and migration for a faqrate_limits table, default config and language entries, service registration, unit tests for limiter behavior, and removal of legacy drop-tables utilities and their tests.

Changes

Cohort / File(s) Summary
Rate Limiter Implementation
phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php, tests/phpMyFAQ/Http/RateLimiterTest.php
Introduces final RateLimiter using a DB-backed fixed-window counter (atomic INSERT/UPDATE + authoritative SELECT), fail-closed on DB errors; exposes headers via public property and getHeaders(); comprehensive unit tests for allow/deny and failure paths.
Schema & Migration
phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php, phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php, tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php, tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php, tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php
Adds faqrate_limits table definition to schema and migration (MySQL/Postgres/SQLite/SQLServer); migration and tests updated/added to include table and new config entries.
Configuration, Service & Translations
phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php, phpmyfaq/src/services.php, phpmyfaq/translations/language_en.php
Adds default config keys api.rateLimit.requests=100 and api.rateLimit.interval=3600; registers phpmyfaq.http.rate-limiter service; adds English language entries for these configs.
Removed Table-Drop Functionality
phpmyfaq/src/phpMyFAQ/Instance/Database.php, phpmyfaq/src/phpMyFAQ/System.php, tests/phpMyFAQ/Instance/DatabaseTest.php
Removes dropTableStmts and dropTables() from Database and System::dropTables() wrapper; deletes related tests. No replacement provided.
Test Cleanup / Misc
tests/phpMyFAQ/Auth/AuthDatabaseTest.php
Extends test setup/teardown to remove both testUser and testUser2 for cleaner test state.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant RL as RateLimiter
    participant Conf as Configuration
    participant DB as Database

    Client->>RL: check(key, limit, interval)
    RL->>Conf: getDb()
    RL->>DB: INSERT window row (attempt)
    DB-->>RL: success / duplicate / fail
    alt duplicate
        RL->>DB: UPDATE increment
        DB-->>RL: success / fail
    end
    RL->>DB: SELECT currentRequests
    DB-->>RL: currentRequests / fail
    alt DB failure or unreadable count
        RL->>Client: deny (false) + headers (Limit, Remaining=0, Reset, Retry-After)
    else if currentRequests > limit
        RL->>Client: deny (false) + headers (Limit, Remaining=0, Reset, Retry-After)
    else
        RL->>Client: allow (true) + headers (Limit, Remaining, Reset)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hop through windows, count each try,
I insert, update, then select on high,
Headers set — limit, remaining, reset,
If DB stumbles, I close the net,
A quiet rabbit guarding your API.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main change: implementation of a rate limiter for the API, which is the primary focus of this changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/rate-limiter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php`:
- Around line 43-108: The check method in RateLimiter has a TOCTOU race between
the SELECT and subsequent INSERT/UPDATE allowing over‑limit requests; fix by
making the increment atomic: either perform an upsert that increments requests
in one statement (e.g. INSERT ... ON DUPLICATE KEY UPDATE requests = requests +
1) against the faqrate_limits table and then read the resulting requests count,
or wrap the SELECT + UPDATE in a DB transaction with a row lock (SELECT ... FOR
UPDATE) before mutating; ensure you update the same logic in RateLimiter::check
to compute remaining/headers from the post‑increment requests value (and handle
insert conflicts/errors) so concurrent requests cannot exceed the limit.
- Around line 78-86: The INSERT in RateLimiter when $currentRequests === 0 can
silently fail on duplicate-key race; change the logic after building
$insertQuery and calling $db->query($insertQuery) to check the return value and,
on failure, perform a defensive fallback UPDATE that increments requests for the
same rate_key/window_start (or re-query the current count and proceed
accordingly). Specifically, in the RateLimiter method that uses $currentRequests
and $insertQuery, detect a failed $db->query() (or a duplicate-key error) and
run an UPDATE ... SET requests = requests + 1 (or reselect and update) for the
same $escapedKey/$windowStart so the second concurrent writer does not get
silently ignored; ensure the method then uses the updated request count to
compute $remaining.

In `@tests/phpMyFAQ/Http/RateLimiterTest.php`:
- Around line 14-65: The RateLimiter::check method has a TOCTOU race between
SELECT and separate INSERT/UPDATE and doesn't verify write success; fix by
making the increment atomic and checking DB results: change RateLimiter::check
to use an atomic upsert (e.g. INSERT ... ON DUPLICATE KEY UPDATE / INSERT ... ON
CONFLICT) or wrap the read+increment in a transaction with SELECT ... FOR UPDATE
to lock the row, then perform the increment/update, and ensure the query/execute
return values are validated (throw or treat as denied on failure). Update
references in RateLimiter::check and any helper DB calls so
remaining/limit/reset headers are computed from the authoritative DB result
only.
🧹 Nitpick comments (3)
phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php (1)

492-501: Consider adding a cleanup strategy for expired rate-limit rows.

Every time window produces a new row per key, and nothing ever deletes old ones. Over time, this table will grow unboundedly. Consider adding a periodic purge (e.g., a cron job or a migration that adds a TTL-based cleanup) to remove rows where window_start + interval < now(). An index on window_start would also help efficient bulk deletes.

phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php (1)

110-116: getHeaders() is redundant with the public $headers property.

Both getHeaders() and $headers expose the same data. Consider keeping only one access path to reduce API surface area. If the property hook is the preferred approach, getHeaders() could be removed (or vice versa).

tests/phpMyFAQ/Http/RateLimiterTest.php (1)

29-50: Test correctly covers the "first request in window" path.

The mock setup simulates no prior requests, and assertions verify the expected headers. Consider also asserting that the INSERT query was issued (e.g., using $this->db->expects($this->once()) for the insert call) to confirm state is persisted.

# Conflicts:
#	phpmyfaq/src/phpMyFAQ/Instance/Database.php
#	phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php
#	tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php
#	tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php
#	tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/translations/language_en.php`:
- Around line 1675-1677: Update the hint for
$LANG_CONF['api.rateLimit.interval'] to match the actual default of 3600
seconds; change the hint text from "Standard: 60 seconds" to "Standard: 3600
seconds (1 hour)" so it aligns with the defaults set in DefaultDataSeeder and
Migration420Alpha.
🧹 Nitpick comments (4)
phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php (1)

525-572: Migration for faqrate_limits is correct across all backends.

The table schema and config entries are properly defined. One operational concern:

No cleanup mechanism for expired rate-limit windows. The faqrate_limits table will accumulate rows indefinitely since old windows are never pruned. Consider adding a scheduled cleanup (e.g., DELETE FROM faqrate_limits WHERE window_start < :cutoff) or documenting that administrators should periodically purge stale rows.

phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php (1)

49-50: $key is interpolated into SQL via sprintf — relies on $db->escape() for safety.

The $escapedKey is produced by $db->escape($key) and then string-interpolated into SQL with single quotes. While this follows the existing codebase pattern (e.g., other repositories using the same DB abstraction), it's inherently fragile — if a future caller passes a key containing unescaped characters that the DB driver doesn't handle, it could lead to SQL injection. Consider documenting that $key must be a safe, predictable value (e.g., IP address or API token hash) rather than arbitrary user input.

tests/phpMyFAQ/Http/RateLimiterTest.php (2)

57-82: Consider adding a boundary test where requests == limit.

The implementation uses $currentRequests > $limit (strict greater-than), meaning the limit-th request is allowed with remaining = 0. There is no test covering this exact boundary (requests == limit), which is where off-by-one bugs typically hide. A test with e.g. limit = 3, requests = 3 asserting check() returns true with remaining = 0 would lock in that behavior.


31-32: Optional: Extract shared mock setup to reduce repetition.

Every test method re-configures escape and now identically. Consider moving those two stubs into setUp() to reduce boilerplate — the per-test query/fetchObject stubs can remain inline since they vary.

♻️ Suggested refactor
 protected function setUp(): void
 {
     parent::setUp();

     Database::setTablePrefix('');
     $this->configuration = $this->createMock(Configuration::class);
     $this->db = $this->createMock(DatabaseDriver::class);
     $this->configuration->method('getDb')->willReturn($this->db);
+
+    $this->db->method('escape')->willReturnCallback(static fn (string $value): string => $value);
+    $this->db->method('now')->willReturn('CURRENT_TIMESTAMP');
 }

Then remove the duplicate escape/now stubs from each test method.

Also applies to: 59-60, 86-87, 113-114, 135-136

@thorsten thorsten merged commit 97f5cc3 into main Feb 9, 2026
8 of 9 checks passed
@thorsten thorsten deleted the feat/rate-limiter branch February 9, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant