La maggior parte delle guide "Upstash quickstart" ti mostra una sola GET e SET. Il pattern che fa guadagnare a Upstash il suo posto in uno stack serverless è quello qui sotto: un rate limiter a finestra scorrevole e un check di token di idempotenza, entrambi che girano dentro una Server Action, entrambi supportati dalla stessa istanza Redis, con il pattern dei webhook Stripe visto prima nello stack che prende in prestito la stessa primitiva di idempotenza.
1. Il rate limiter
@upstash/ratelimit fa la correttezza algoritmica dentro Redis con MULTI/EXEC. Il codice applicativo è una riga al call site.
// src/lib/upstash/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
export const ipLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '10 s'),
prefix: 'rl:ip',
analytics: true,
})
export const userLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '60 s'),
prefix: 'rl:user',
analytics: true,
})
export const tenantLimiter = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(1000, '60 s', 1000),
prefix: 'rl:tenant',
analytics: true,
})
export async function checkRate(opts: {
ip: string
userId: string
tenantId: string
}): Promise<
| { ok: true }
| { ok: false; scope: 'ip' | 'user' | 'tenant'; reset: number }
> {
const ipResult = await ipLimiter.limit(opts.ip)
if (!ipResult.success) return { ok: false, scope: 'ip', reset: ipResult.reset }
const userResult = await userLimiter.limit(opts.userId)
if (!userResult.success) return { ok: false, scope: 'user', reset: userResult.reset }
const tenantResult = await tenantLimiter.limit(opts.tenantId)
if (!tenantResult.success) return { ok: false, scope: 'tenant', reset: tenantResult.reset }
return { ok: true }
}
2. L'helper di idempotenza
Una chiave unica codifica la forma della richiesta; la risposta è cachata contro quella chiave. Un retry con la stessa chiave ritorna la risposta originale senza rieseguire il lavoro.
// src/lib/upstash/idempotency.ts
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
const TTL_SECONDS = 24 * 60 * 60
interface RecordedResult<T> {
status: 'success' | 'error'
response: T
}
export async function withIdempotency<T>(
key: string,
run: () => Promise<T>,
): Promise<T> {
const fullKey = `idem:${key}`
const existing = await redis.get<RecordedResult<T>>(fullKey)
if (existing) {
if (existing.status === 'error') {
throw new Error('previous attempt failed; retry with a different key')
}
return existing.response
}
try {
const result = await run()
await redis.set(
fullKey,
{ status: 'success', response: result } satisfies RecordedResult<T>,
{ ex: TTL_SECONDS },
)
return result
} catch (err) {
await redis.set(
fullKey,
{ status: 'error', response: null } satisfies RecordedResult<unknown>,
{ ex: 60 },
)
throw err
}
}
3. La Server Action che usa entrambi
La Server Action prende un header Idempotency-Key, esegue il rate limiter, poi esegue la logica di business dentro l'helper di idempotenza. Una POST in retry con la stessa chiave ritorna la risposta originale; una scrittura duplicata non avviene mai.
// app/[lang]/(app)/invoices/actions.ts
'use server'
import { headers } from 'next/headers'
import { z } from 'zod'
import { checkRate } from '@/lib/upstash/ratelimit'
import { withIdempotency } from '@/lib/upstash/idempotency'
import { getServerSession } from '@/lib/auth/server'
import { stripe } from '@/lib/stripe/server'
const Input = z.object({
customerId: z.string(),
amountCents: z.number().int().positive(),
description: z.string().min(1).max(200),
})
export async function createInvoice(
raw: unknown,
): Promise<
| { ok: true; invoiceId: string }
| { ok: false; error: string; reset?: number }
> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const hdrs = await headers()
const ip = hdrs.get('x-forwarded-for')?.split(',')[0] ?? '0.0.0.0'
const idempotencyKey = hdrs.get('idempotency-key')
if (!idempotencyKey) return { ok: false, error: 'missing idempotency key' }
const rate = await checkRate({
ip,
userId: session.userId,
tenantId: session.tenantId,
})
if (!rate.ok) {
return { ok: false, error: `rate limited (${rate.scope})`, reset: rate.reset }
}
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
return withIdempotency(`invoice:${session.tenantId}:${idempotencyKey}`, async () => {
const invoice = await stripe.invoices.create({
customer: parsed.data.customerId,
collection_method: 'send_invoice',
days_until_due: 30,
description: parsed.data.description,
metadata: { tenant_id: session.tenantId },
})
await stripe.invoiceItems.create({
customer: parsed.data.customerId,
amount: parsed.data.amountCents,
currency: 'eur',
invoice: invoice.id,
})
return { ok: true as const, invoiceId: invoice.id }
})
}
4. Cosa ti compra questo
Il rate limiter gira in un paio di millisecondi in edge. L'helper di idempotenza trasforma una POST in retry in una lookup di cache, così l'API Stripe non vede mai un duplicato. Tutto il flusso vive in tre file; il livello Redis non compare nella logica applicativa se non come due helper che si leggono come funzioni normali.
Questo è l'Upstash che si guadagna il suo posto: non una curiosità "abbiamo sostituito Redis con HTTP", ma il livello operativo che rende i deploy serverless davvero sicuri sotto retry e traffico alto.