Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"@vercel/og": "^0.9.0",
"colord": "^2.9.3",
"fast-average-color": "^9.5.0",
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/card/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function GET(
const fontUrl = `${allowedOrigin}/fonts/NotoSans-Regular.ttf`;

const ip = request.headers.get("x-forwarded-for") ?? "unknown";
const rateLimitResult = rateLimiter.check(ip);
const rateLimitResult = await rateLimiter.check(ip);

if (!rateLimitResult.success) {
return renderErrorCardResponse({
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/og/[username]/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded ? forwarded.split(",").at(-1)?.trim() ?? "unknown" : "unknown";
const rateLimitResult = rateLimiter.check(ip);
const rateLimitResult = await rateLimiter.check(ip);

if (!rateLimitResult.success) {
const retryAfterSec = Math.ceil((rateLimitResult.reset - Date.now()) / 1000);
Expand Down Expand Up @@ -84,7 +84,7 @@
}}
>
{avatarUrl && (
<img

Check warning on line 87 in src/app/api/og/[username]/route.tsx

View workflow job for this annotation

GitHub Actions / Lint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={sanitizeUrl(avatarUrl)}
alt=""
width={120}
Expand Down
95 changes: 76 additions & 19 deletions src/lib/__tests__/rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,98 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { RateLimiter } from "../rateLimit";
import { Ratelimit } from "@upstash/ratelimit";

Check warning on line 3 in src/lib/__tests__/rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Lint

'Ratelimit' is defined but never used

vi.mock("@upstash/redis", () => {
return {
Redis: class {
constructor() {}
}
};
});

vi.mock("@upstash/ratelimit", () => {
const mockLimit = vi.fn().mockResolvedValue({ success: true, reset: 12345 });
return {
Ratelimit: class {
static slidingWindow = vi.fn().mockReturnValue("sliding-window-algo");
limit = mockLimit;
}
};
});

describe("RateLimiter", () => {
const originalEnv = process.env;

beforeEach(() => {
vi.useFakeTimers();
process.env = { ...originalEnv };
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
});

afterEach(() => {
vi.restoreAllMocks();
process.env = originalEnv;
});

it("allows requests below the limit", () => {
const limiter = new RateLimiter(2, 1000);
const key = "test-key";
describe("In-memory Fallback", () => {
it("allows requests below the limit", async () => {
const limiter = new RateLimiter(2, 1000);
const key = "test-key";

expect(limiter.check(key).success).toBe(true);
expect(limiter.check(key).success).toBe(true);
});
expect((await limiter.check(key)).success).toBe(true);
expect((await limiter.check(key)).success).toBe(true);
});

it("blocks requests above the limit", async () => {
const limiter = new RateLimiter(2, 1000);
const key = "test-key";

await limiter.check(key);
await limiter.check(key);
expect((await limiter.check(key)).success).toBe(false);
});

it("resets after the window has passed", async () => {
const limiter = new RateLimiter(1, 1000);
const key = "test-key";

expect((await limiter.check(key)).success).toBe(true);
expect((await limiter.check(key)).success).toBe(false);

vi.advanceTimersByTime(1001);

expect((await limiter.check(key)).success).toBe(true);
});

it("lazy cleans up properly", async () => {
const limiter = new RateLimiter(1, 1000);
const key1 = "test-key-1";
const key2 = "test-key-2";

it("blocks requests above the limit", () => {
const limiter = new RateLimiter(2, 1000);
const key = "test-key";
await limiter.check(key1);
vi.advanceTimersByTime(1001);
await limiter.check(key2); // triggers cleanup for key1

limiter.check(key);
limiter.check(key);
expect(limiter.check(key).success).toBe(false);
// internal check would be needed, but essentially the fact it works implies cleanup did not crash
expect((await limiter.check(key2)).success).toBe(false);
});
});

it("resets after the window has passed", () => {
const limiter = new RateLimiter(1, 1000);
const key = "test-key";
describe("Upstash Redis", () => {
beforeEach(() => {
process.env.UPSTASH_REDIS_REST_URL = "https://fake-url";
process.env.UPSTASH_REDIS_REST_TOKEN = "fake-token";
});

expect(limiter.check(key).success).toBe(true);
expect(limiter.check(key).success).toBe(false);
it("uses Upstash Ratelimit when env vars are present", async () => {
const limiter = new RateLimiter(2, 1000);
const key = "upstash-key";

vi.advanceTimersByTime(1001);
const result = await limiter.check(key);

expect(limiter.check(key).success).toBe(true);
expect(result.success).toBe(true);
expect(result.reset).toBe(12345);
});
});
});
26 changes: 23 additions & 3 deletions src/lib/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import { Redis } from "@upstash/redis";
import { Ratelimit } from "@upstash/ratelimit";

export class RateLimiter {
private cache = new Map<string, { count: number; resetTime: number }>();

constructor(private limit: number, private windowMs: number) {}
private upstashRatelimit: Ratelimit | null = null;

constructor(private limit: number, private windowMs: number) {
if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
this.upstashRatelimit = new Ratelimit({
redis: redis,
limiter: Ratelimit.slidingWindow(this.limit, `${this.windowMs} ms`),
});
}
}

private cleanup(now: number) {
for (const [key, record] of this.cache.entries()) {
Expand All @@ -12,7 +26,13 @@ export class RateLimiter {
}
}

check(key: string): { success: boolean; reset: number } {
async check(key: string): Promise<{ success: boolean; reset: number }> {
if (this.upstashRatelimit) {
const { success, reset } = await this.upstashRatelimit.limit(key);
return { success, reset };
}
Comment on lines +30 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The call to this.upstashRatelimit.limit(key) performs a network request to the Upstash Redis service. If the service is unreachable or encounters a timeout, this call will throw an exception, causing the entire API route to fail with a 500 error. It is recommended to wrap this call in a try...catch block and fall back to the in-memory limiter to ensure the application remains functional. When implementing this fallback, ensure the error is logged to facilitate debugging, as per repository guidelines for handling suppressed rejections.

References
  1. When suppressing unhandled promise rejections, log the error instead of silently ignoring it to facilitate debugging.

Comment on lines +29 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Redisエラー時のフォールバック処理が不足しています。upstashRatelimit.limit(key) がネットワーク障害や接続エラーでthrowした場合、例外がそのまま呼び出し元まで伝播し、APIが500エラーを返します。Redisが一時的に利用できない状況では、レート制限が機能しなくなる代わりにAPIそのものがダウンするため、インメモリのフォールバックに切り替える方が安全です。

Suggested change
async check(key: string): Promise<{ success: boolean; reset: number }> {
if (this.upstashRatelimit) {
const { success, reset } = await this.upstashRatelimit.limit(key);
return { success, reset };
}
async check(key: string): Promise<{ success: boolean; reset: number }> {
if (this.upstashRatelimit) {
try {
const { success, reset } = await this.upstashRatelimit.limit(key);
return { success, reset };
} catch {
// Redis が一時的に利用不可の場合はインメモリフォールバックで継続
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/rateLimit.ts
Line: 29-33

Comment:
Redisエラー時のフォールバック処理が不足しています。`upstashRatelimit.limit(key)` がネットワーク障害や接続エラーでthrowした場合、例外がそのまま呼び出し元まで伝播し、APIが500エラーを返します。Redisが一時的に利用できない状況では、レート制限が機能しなくなる代わりにAPIそのものがダウンするため、インメモリのフォールバックに切り替える方が安全です。

```suggestion
    async check(key: string): Promise<{ success: boolean; reset: number }> {
        if (this.upstashRatelimit) {
            try {
                const { success, reset } = await this.upstashRatelimit.limit(key);
                return { success, reset };
            } catch {
                // Redis が一時的に利用不可の場合はインメモリフォールバックで継続
            }
        }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +29 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant section of src/lib/rateLimit.ts
if [ -f "src/lib/rateLimit.ts" ]; then
  nl -ba src/lib/rateLimit.ts | sed -n '1,220p'
else
  echo "Missing file: src/lib/rateLimit.ts"
fi

# Find where RateLimiter.check is called
rg -n "rateLimit\.check\(|\.check\(\s*key|new RateLimiter|class RateLimiter" -S src || true

# Specifically search for ".check(" usage on rate limiter instances
rg -n "\.check\(" -S src/lib src || true

# Search for "upstashRatelimit.limit" usage
rg -n "upstashRatelimit\.limit|upstashRatelimit" -S src || true

Repository: Hiroki-org/github-user-summary

Length of output: 117


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant portion of src/lib/rateLimit.ts
if [ -f "src/lib/rateLimit.ts" ]; then
  echo "=== src/lib/rateLimit.ts (1-220) ==="
  sed -n '1,220p' src/lib/rateLimit.ts | cat -n
else
  echo "Missing file: src/lib/rateLimit.ts"
fi

echo "=== search: RateLimiter.check call sites ==="
rg -n "rateLimit\.check\(|new RateLimiter|class RateLimiter" -S src || true

echo "=== search: .check( usage ==="
rg -n "\.check\(" -S src/lib src || true

echo "=== search: upstashRatelimit.limit / upstashRatelimit ==="
rg -n "upstashRatelimit\.limit|upstashRatelimit" -S src || true

Repository: Hiroki-org/github-user-summary

Length of output: 6004


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/app/api/og/[username]/route.tsx (1-140) ==="
sed -n '1,160p' src/app/api/og/[username]/route.tsx | cat -n

echo "=== src/app/api/card/[username]/route.ts (1-140) ==="
sed -n '1,180p' src/app/api/card/[username]/route.ts | cat -n

echo "=== src/lib/__tests__/rateLimit.test.ts (1-200) ==="
sed -n '1,240p' src/lib/__tests__/rateLimit.test.ts | cat -n

Repository: Hiroki-org/github-user-summary

Length of output: 12427


Upstash 側 limit() の reject がそのまま例外伝播して 500 になり得るため、check() で try/catch して in-memory にフォールバックすべきです

src/lib/rateLimit.ts (29-33) で this.upstashRatelimit.limit(key) が reject した場合に例外を握りつぶさず、そのまま src/app/api/og/[username]/route.tsx / src/app/api/card/[username]/route.tsrateLimiter.check(ip) 呼び出しへ伝播し、両 route とも check() 周りを try/catch していないためです。

💡 提案差分
 async check(key: string): Promise<{ success: boolean; reset: number }> {
   if (this.upstashRatelimit) {
-      const { success, reset } = await this.upstashRatelimit.limit(key);
-      return { success, reset };
+      try {
+          const { success, reset } = await this.upstashRatelimit.limit(key);
+          return { success, reset };
+      } catch {
+          // Upstash到達不可時は in-memory fallback に委譲
+      }
   }

   // Fallback to in-memory caching
   const now = Date.now();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/rateLimit.ts` around lines 29 - 33, The call to
this.upstashRatelimit.limit(key) in the check() method can reject and bubble up
a 500; wrap that call in a try/catch inside check(), log the error, and on
failure return the result from the in-memory fallback rate limiter (i.e., call
the class's in-memory limiter method/field instead of letting the exception
propagate). Specifically, modify check() so it tries await
this.upstashRatelimit.limit(key) but on catch returns the equivalent response
from your local/in-memory limiter (and include a brief error log), ensuring
callers like route handlers calling rateLimiter.check(ip) never get an unhandled
exception.


// Fallback to in-memory caching
const now = Date.now();
this.cleanup(now); // Lazy cleanup

Expand Down
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineConfig({
"src/components/LanguageChart.tsx",
"src/components/SkillsCard.tsx",
"src/components/LayoutEditor.tsx",
"src/lib/rateLimit.ts",
],
thresholds: {
lines: 80,
Expand Down
Loading