Skip to content

Commit 40ddae5

Browse files
committed
Normalize BetterAuth errors and handle rate limits
1 parent 87b25b6 commit 40ddae5

2 files changed

Lines changed: 95 additions & 7 deletions

File tree

apps/api/src/middleware.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { normalizeApiKeyVerificationError } from './middleware.js'
3+
4+
describe('normalizeApiKeyVerificationError', () => {
5+
test('maps Better Auth rate limit errors to RateLimitedError', () => {
6+
const error = normalizeApiKeyVerificationError({
7+
statusCode: 401,
8+
body: {
9+
code: 'RATE_LIMITED',
10+
message: 'Rate limit exceeded.',
11+
details: {
12+
retryAfter: 17,
13+
},
14+
},
15+
headers: {},
16+
})
17+
18+
expect(error._tag).toBe('RateLimitedError')
19+
expect(error.message).toBe('Rate limit exceeded.')
20+
expect('retryAfter' in error ? error.retryAfter : null).toBe(17)
21+
})
22+
23+
test('falls back to UnauthorizedError for non-rate-limit failures', () => {
24+
const error = normalizeApiKeyVerificationError({
25+
statusCode: 401,
26+
body: {
27+
code: 'UNAUTHORIZED',
28+
message: 'Invalid API key',
29+
},
30+
headers: {},
31+
})
32+
33+
expect(error._tag).toBe('UnauthorizedError')
34+
expect(error.message).toBe('Invalid API key')
35+
})
36+
})

apps/api/src/middleware.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,60 @@
11
import { HttpMiddleware, HttpServerRequest, HttpServerResponse } from '@effect/platform'
2-
import { Effect } from 'effect'
2+
import { Effect, Either } from 'effect'
33
import { timingSafeEqual } from 'node:crypto'
44
import { parseScopes } from '@sandchest/contract'
5-
import { UnauthorizedError, formatApiError } from './errors.js'
5+
import { RateLimitedError, UnauthorizedError, formatApiError } from './errors.js'
66
import { AuthContext } from './context.js'
77
import { auth } from './auth.js'
88
import { loadEnv } from './env.js'
99

10+
type BetterAuthErrorShape = {
11+
statusCode?: number
12+
headers?: Record<string, string>
13+
body?: {
14+
code?: string
15+
message?: string
16+
details?: Record<string, unknown>
17+
}
18+
}
19+
20+
function extractRetryAfterSeconds(error: BetterAuthErrorShape): number {
21+
const headerValue = error.headers?.['retry-after']
22+
const headerSeconds = headerValue ? Number(headerValue) : NaN
23+
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
24+
return headerSeconds
25+
}
26+
27+
const details = error.body?.details
28+
const candidates = [
29+
details?.['retryAfter'],
30+
details?.['retry_after'],
31+
]
32+
33+
for (const value of candidates) {
34+
const seconds = typeof value === 'number' ? value : Number(value)
35+
if (Number.isFinite(seconds) && seconds > 0) {
36+
return seconds
37+
}
38+
}
39+
40+
return 60
41+
}
42+
43+
export function normalizeApiKeyVerificationError(error: unknown) {
44+
const candidate = error as BetterAuthErrorShape | undefined
45+
const code = candidate?.body?.code
46+
const message = candidate?.body?.message
47+
48+
if (code === 'RATE_LIMITED') {
49+
return new RateLimitedError({
50+
message: message ?? 'Rate limit exceeded.',
51+
retryAfter: extractRetryAfterSeconds(candidate ?? {}),
52+
})
53+
}
54+
55+
return new UnauthorizedError({ message: 'Invalid API key' })
56+
}
57+
1058
/**
1159
* Generates a request ID (or propagates from X-Request-Id header)
1260
* and attaches it to the response.
@@ -59,12 +107,16 @@ export const withAuth = HttpMiddleware.make((app) =>
59107
const authHeader = request.headers['authorization']
60108
if (authHeader?.startsWith('Bearer ')) {
61109
const key = authHeader.slice(7)
62-
const result = yield* Effect.tryPromise({
110+
const verification = yield* Effect.either(Effect.tryPromise({
63111
try: () => auth.api.verifyApiKey({ body: { key } }),
64-
catch: () => new UnauthorizedError({ message: 'Invalid API key' }),
65-
}).pipe(
66-
Effect.catchAll(() => Effect.succeed(null)),
67-
)
112+
catch: normalizeApiKeyVerificationError,
113+
}))
114+
115+
if (Either.isLeft(verification)) {
116+
return formatApiError(verification.left)
117+
}
118+
119+
const result = verification.right
68120

69121
if (!result?.valid) {
70122
return formatApiError(new UnauthorizedError({ message: 'Invalid API key' }))

0 commit comments

Comments
 (0)