Idempotency Keys لـ Node.js API مع
٦ يونيو ٢٠٢٦
يجعل مفتاح عدم التكرار (idempotency key) عمليات POST أو PATCH غير الآمنة آمنة عند إعادة المحاولة: يرسل العميل ترويسة Idempotency-Key فريدة، ويقوم الخادم بتسجيلها في Postgres بحيث يقوم الطلب المعاد تشغيله بإعادة عرض الاستجابة الأصلية بدلاً من تنفيذ الإجراء مرتين. يبني هذا الدليل تلك الطبقة في TypeScript على Node 24، من البداية إلى النهاية.
ملخص
يبني هذا الدليل العملي طبقة عدم تكرار جاهزة للإنتاج لـ Node.js API باستخدام Fastify 5.8.51، و node-postgres 8.21.02، و Postgres 18.43 على Node 24 LTS. ستقوم بنمذجة جدول idempotency_keys، وبصمة كل طلب بحيث يتم رفض المفتاح المعاد استخدامه مع جسم طلب مختلف، وحجز المفتاح بعملية ذرية واحدة INSERT ... ON CONFLICT، والتفرع إلى النتائج الخمس التي توضحها مسودة IETF4: المعالجة لمرة واحدة، إعادة عرض استجابة مكتملة، إرجاع 409 لإعادة محاولة متزامنة، 422 لمفتاح معاد استخدامه، و 400 عند فقدان الترويسة. تم فحص كل ملف برمجياً تحت TypeScript 6.0.3 الصارم وتشغيله بالكامل مقابل Postgres في 6 يونيو 2026. الميزانية الزمنية حوالي 35-45 دقيقة.
ما ستتعلمه
- لماذا تحتاج عمليات
POSTوPATCHإلى مفاتيح عدم تكرار، وماذا تتطلب مسودة IETF لـIdempotency-Key - كيفية تصميم جدول
idempotency_keysيحدد نطاق المفاتيح لكل حساب - كيفية أخذ بصمة للطلب بحيث يتم اكتشاف المفتاح المعاد استخدامه مع حمولة مختلفة
- كيفية حجز مفتاح بشكل ذري باستخدام
INSERT ... ON CONFLICT DO NOTHING - كيفية ربط المنظومة بالكامل كخطاف
preHandlerفي Fastify يعيد400، أو409، أو422، أو إعادة عرض للاستجابة - كيفية إنهاء صلاحية وتنظيف المفاتيح المخزنة دون تضخيم الجدول
- المقايضة في التزامن بين اعتماد الحجز أولاً أو إبقاء المعاملة (transaction) مفتوحة
المتطلبات الأساسية
- Node.js 24 (Active LTS، مدعوم حتى أبريل 2028) أو Node 22 Maintenance LTS5. يقوم Node 24 بتشغيل ملفات
.tsمباشرة، لذا لا توجد خطوة بناء في مرحلة التطوير. - Docker مع صورة
postgres:18.4الرسمية3. - إلمام أساسي بأفعال HTTP و
async/await. لا يشترط خبرة سابقة في Fastify.
الإصدارات مثبتة طوال الدليل. لصق latest في برنامج تعليمي هو الطريقة التي يتعطل بها مثال ناجح بعد ثلاثة أسابيع.
لماذا يحتاج POST إلى مفتاح عدم تكرار
وفقاً لـ RFC 9110، فإن عمليات GET و PUT و DELETE هي عمليات غير متكررة (idempotent) — إرسالها مرتين له نفس تأثير إرسالها مرة واحدة. أما POST و PATCH فليستا كذلك6. هذا أمر مقبول حتى يحدث خلل في الشبكة يؤدي لانتهاء وقت طلب "إنشاء دفع". لم يرَ العميل استجابة أبداً، لذا يعيد المحاولة. إذا نجح الطلب الأول بالفعل على الخادم، فسيتم محاسبة العميل مرتين.
مفتاح عدم التكرار يحل هذه المشكلة. يقوم العميل بتوليد قيمة فريدة — يوصى باستخدام UUID4 — ويرسلها مع كل إعادة محاولة لنفس العملية المنطقية:
POST /v1/payments HTTP/1.1
Host: API.example.com
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
X-Account-Id: acct_123
Content-Type: application/json
{ "amount": 5000, "currency": "usd" }
تحدد مسودة IETF رسمياً قيمة الترويسة كسلسلة نصية (String) ذات ترويسة مهيكلة ومقتبسة4، ولكن معظم واجهات برمجة التطبيقات في الإنتاج — ومن بينها Stripe7 — والمعالج الذي نبنيه هنا يعاملون ما يرسله العميل كرمز غير شفاف (opaque token)، لذا تستخدم الأمثلة الـ UUID المجرد.
يتذكر الخادم ذلك المفتاح. في المرة الأولى التي يراه فيها، يقوم بالعمل ويخزن النتيجة. كل طلب لاحق بنفس المفتاح يحصل على النتيجة المخزنة — لا توجد عملية دفع ثانية. تقوم Stripe و Adyen و Dwolla و WorldPay جميعاً بتنفيذ هذه الترويسة بالضبط4، وقد اشتهر هذا النمط من خلال كتابات Stripe الهندسية7 وتصميم Postgres المرجعي لـ Brandur Leach8.
القرار الذي يتخذه الخادم في كل طلب يبدو كالتالي:
flowchart TD
A[Request with Idempotency-Key] --> B{INSERT ... ON CONFLICT<br/>DO NOTHING — row inserted?}
B -- yes, first time --> C[Process the operation<br/>store response, return 201]
B -- no, key exists --> D{Fingerprint matches<br/>stored fingerprint?}
D -- no --> E[422 — key reused<br/>with a different body]
D -- yes --> F{Status?}
F -- in_progress --> G[409 — a request is<br/>still outstanding]
F -- completed --> H[Replay stored<br/>status + body]
الخطوة 1 — هيكلة المشروع
أنشئ دليلاً وثبت كل تبعية. علامة --save-exact مهمة: بدونها، يكتب npm نطاقاً (caret range) ويمكن لعملية التثبيت التالية أن تجلب إصداراً أحدث من الذي اختبرته.
mkdir idempotent-API && cd idempotent-API
npm init -y
npm pkg set type=module
npm install --save-exact fastify@5.8.5 pg@8.21.0
npm install --save-exact -D TypeScript@6.0.3 tsx@4.22.4 @types/node@24.13.1 @types/pg@8.20.0
أضف ملف tsconfig.json صارماً. تتيح لك خاصية allowImportingTsExtensions كتابة امتدادات استيراد .ts الصريحة التي يتطلبها محمل TypeScript الأصلي في Node 24:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
ابدأ تشغيل Postgres في Docker:
Docker run --name idem-pg -e POSTGRES_PASSWORD=secret \
-p 5432:5432 -d postgres:18.4
الخطوة 2 — تصميم جدول idempotency_keys
الجدول هو النظام بأكمله. يتتبع كل صف تقدم عملية واحدة ويخزن استجابتها النهائية.
-- schema.sql
CREATE TABLE IF NOT EXISTS idempotency_keys (
account_id text NOT NULL,
idempotency_key text NOT NULL,
request_fingerprint text NOT NULL,
status text NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress', 'completed')),
response_code integer,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours',
PRIMARY KEY (account_id, idempotency_key)
);
هناك ثلاثة خيارات تصميمية تقوم بالعمل الحقيقي هنا.
المفتاح الأساسي مركب — (account_id, idempotency_key)، وليس المفتاح وحده. يوصي قسم الأمان في مسودة IETF بدمج مفتاح العميل مع سمة معروفة للخادم حتى لا يتمكن مستأجر واحد من تخمين مفاتيح مستأجر آخر وقراءة استجاباته المخزنة4. تحديد النطاق حسب الحساب يعني أيضاً أن عميلين يمكنهما اختيار نفس الـ UUID بشكل مستقل دون حدوث تصادم.
عمود status هو آلة حالة (state machine) صغيرة: ينشأ الصف بحالة in_progress ويتحول إلى completed بمجرد انتهاء العمل وتخزين الاستجابة. يحافظ قيد CHECK على نزاهة هذه الحالة.
يتيح لك عمود request_fingerprint اكتشاف إعادة استخدام المفتاح مع حمولة مختلفة، وهو ما تنص المواصفات على رفضه برمز 4224. سنقوم بحسابه تالياً.
طبق المخطط (schema):
Docker exec -i idem-pg psql -U postgres < schema.sql
الخطوة 3 — أخذ بصمة للطلب
البصمة هي تشفير (hash) لأجزاء الطلب التي تحدد العملية: الطريقة (method)، والمسار (path)، والجسم (body). قم بتشفير الجسم من خلال محول نصوص مستقر بحيث ينتج عن {"amount":5000,"currency":"usd"} و {"currency":"usd","amount":5000} نفس البصمة — لا ينبغي أبداً لترتيب مفاتيح JSON أن يتسبب في إرجاع 422 خاطئ.
// src/fingerprint.ts
import { createHash } from 'node:crypto';
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
export function stableStringify(value: Json): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
const keys = Object.keys(value).sort();
return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k] as Json)).join(',') + '}';
}
export function requestFingerprint(method: string, path: string, body: Json): string {
return createHash('sha256')
.update(`${method}\n${path}\n${stableStringify(body)}`)
.digest('hex');
}
تأتي مكتبة node:crypto مدمجة مع Node، لذا لا توجد تبعية لإضافتها. يمنحك SHA-256 hex سلسلة نصية ثابتة العرض ومقاومة للتصادم لتخزينها ومقارنتها.
الخطوة 4 — حجز المفتاح بشكل ذري
هذا هو قلب النظام، والجزء الذي تخطئ فيه الشروحات السطحية. النهج الساذج — SELECT للتحقق مما إذا كان المفتاح موجودًا، ثم INSERT إذا لم يكن موجودًا — يحتوي على سباق (race condition): محاولتان متزامنتان تقومان بـ SELECT ولا تجدان شيئًا، فتقومان بـ INSERT معًا، وتعود مرة أخرى لمشكلة الخصم المزدوج.
الحل هو ترك Postgres يقوم بتسلسل عملية الحجز (claim) باستخدام جملة ذرية (atomic) واحدة. INSERT ... ON CONFLICT (account_id, idempotency_key) DO NOTHING RETURNING تقوم بإدراج الصف إذا كان المفتاح جديدًا وتعيده؛ أما إذا كان المفتاح موجودًا بالفعل، فإنها لا تعيد أي صفوف9. طلب متزامن واحد بالضبط يمكنه الفوز بعملية الإدراج.
أولاً، واجهة استعلام بسيطة حتى يكون المنطق قابلاً للاختبار مقابل أي مشغل متوافق مع Postgres:
// src/db.ts
export interface QueryResult<R> {
rows: R[];
}
export type QueryFn = <R>(text: string, params?: unknown[]) => Promise<QueryResult<R>>;
الآن عملية الحجز وتوابعها:
// src/idempotency.ts
import type { QueryFn } from './db.ts';
interface KeyRow {
request_fingerprint: string;
status: 'in_progress' | 'completed';
response_code: number | null;
response_body: unknown;
}
export type ClaimResult =
| { kind: 'new' }
| { kind: 'concurrent' }
| { kind: 'mismatch' }
| { kind: 'replay'; code: number; body: unknown };
export async function claimKey(
query: QueryFn,
accountId: string,
key: string,
fingerprint: string,
): Promise<ClaimResult> {
const inserted = await query<{ account_id: string }>(
`INSERT INTO idempotency_keys (account_id, idempotency_key, request_fingerprint)
VALUES ($1, $2, $3)
ON CONFLICT (account_id, idempotency_key) DO NOTHING
RETURNING account_id`,
[accountId, key, fingerprint],
);
if (inserted.rows.length === 1) return { kind: 'new' };
const existing = await query<KeyRow>(
`SELECT request_fingerprint, status, response_code, response_body
FROM idempotency_keys
WHERE account_id = $1 AND idempotency_key = $2`,
[accountId, key],
);
const row = existing.rows[0];
if (!row) return { kind: 'new' };
if (row.request_fingerprint !== fingerprint) return { kind: 'mismatch' };
if (row.status === 'in_progress') return { kind: 'concurrent' };
return { kind: 'replay', code: row.response_code ?? 200, body: row.response_body };
}
export async function completeKey(
query: QueryFn,
accountId: string,
key: string,
code: number,
body: unknown,
): Promise<void> {
await query(
`UPDATE idempotency_keys
SET status = 'completed', response_code = $3, response_body = $4
WHERE account_id = $1 AND idempotency_key = $2`,
[accountId, key, code, JSON.stringify(body)],
);
}
export async function releaseKey(query: QueryFn, accountId: string, key: string): Promise<void> {
await query(
`DELETE FROM idempotency_keys WHERE account_id = $1 AND idempotency_key = $2`,
[accountId, key],
);
}
لاحظ ترتيب الفروع: يتم التحقق من عدم تطابق البصمة (fingerprint mismatch) قبل حالة in_progress. إعادة استخدام مفتاح مع جسم طلب (body) مختلف يعتبر خطأ 422 سواء انتهى الطلب الأول أم لا — العميل ارتكب خطأ لا يمكن للخادم التوفيق بينه. المحاولة الحقيقية ترسل نفس الجسم، لذا تتطابق بصمتها وتنتقل إلى فروع 409 أو إعادة التشغيل (replay). تقوم releaseKey بحذف الحجز إذا فشلت العملية التجارية، حتى يتمكن العميل من المحاولة مرة أخرى بأمان بدلاً من البقاء عالقًا في خطأ 409 للأبد.
الخطوة 5 — ربط Fastify preHandler
يعمل خطاف (hook) preHandler بعد تحليل جسم الطلب ولكن قبل معالج المسار (route handler) الخاص بك. إنه المكان المثالي لفرض خاصية "idempotency" لجميع المسارات التي تقوم بتعديل البيانات في وقت واحد.
// src/app.ts
import Fastify, { type FastifyInstance } from 'fastify';
import type { QueryFn } from './db.ts';
import { requestFingerprint } from './fingerprint.ts';
import { claimKey, completeKey, releaseKey } from './idempotency.ts';
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
declare module 'fastify' {
interface FastifyRequest {
idempotency?: { accountId: string; key: string };
}
}
const problem = (type: string, title: string, detail: string) => ({
type: `https://API.example.com/errors/${type}`,
title,
detail,
});
export function buildApp(query: QueryFn): FastifyInstance {
const app = Fastify({ logger: false });
app.addHook('preHandler', async (req, reply) => {
if (req.method !== 'POST' && req.method !== 'PATCH') return;
const key = req.headers['idempotency-key'];
if (typeof key !== 'string' || key.length === 0) {
return reply.code(400).type('application/problem+json').send(
problem('idempotency-key-missing', 'Idempotency-Key is missing',
'This operation requires a unique Idempotency-Key request header.'),
);
}
const accountId = req.headers['x-account-id'];
if (typeof accountId !== 'string' || accountId.length === 0) {
return reply.code(401).send(problem('unauthorized', 'Unauthorized', 'Missing account context.'));
}
const fingerprint = requestFingerprint(req.method, req.url, (req.body ?? null) as Json);
const result = await claimKey(query, accountId, key, fingerprint);
switch (result.kind) {
case 'new':
req.idempotency = { accountId, key };
return;
case 'concurrent':
return reply.code(409).type('application/problem+json').send(
problem('idempotency-conflict', 'A request is outstanding for this Idempotency-Key',
'A request with the same Idempotency-Key is still being processed.'),
);
case 'mismatch':
return reply.code(422).type('application/problem+json').send(
problem('idempotency-key-reused', 'Idempotency-Key is already used',
'This Idempotency-Key was already used with a different request payload.'),
);
case 'replay':
return reply.code(result.code).send(result.body);
}
});
registerRoutes(app, query);
return app;
}
يعود الخطاف مبكرًا في حالات 400، و 409، و 422، وإعادة التشغيل — ولا يتم تشغيل معالج المسار أبدًا. فقط حالة new هي التي تمر، وتقوم بتخزين { accountId, key } في الطلب حتى يعرف المعالج أي صف يجب إكماله. تستخدم أجسام الأخطاء application/problem+json (RFC 9457، خليفة RFC 7807)، وهو بالضبط الشكل الذي تظهره مسودة IETF4.
الخطوة 6 — مسار العمل وإكمال المفتاح
الآن المسار الذي يقوم بالعمل الفعلي. بعد نجاحه، يقوم بتحويل الصف المحجوز إلى completed ويخزن الاستجابة حتى تقوم المحاولات المستقبلية بإعادة تشغيلها. إذا فشل العمل، فإنه يحرر المفتاح.
// add to src/app.ts
function registerRoutes(app: FastifyInstance, query: QueryFn): void {
app.post('/v1/payments', async (req, reply) => {
const body = (req.body ?? {}) as { amount?: number; currency?: string };
try {
// عملك الحقيقي يتم هنا: خصم من بطاقة، إدراج صف، الاتصال بمزود خدمة.
const payment = {
id: `pay_${Math.random().toString(36).slice(2, 10)}`,
amount: body.amount ?? 0,
currency: body.currency ?? 'usd',
status: 'succeeded',
};
if (req.idempotency) {
await completeKey(query, req.idempotency.accountId, req.idempotency.key, 201, payment);
}
return reply.code(201).send(payment);
} catch (err) {
if (req.idempotency) {
await releaseKey(query, req.idempotency.accountId, req.idempotency.key);
}
throw err;
}
});
}
في خدمة حقيقية، تنتمي عملية كتابة العمل وتحديث completeKey إلى نفس معاملة قاعدة البيانات (database transaction)، بحيث يتم تثبيت صف الدفع وعلامة "هذا المفتاح اكتمل" معًا أو لا يتم تثبيتهما على الإطلاق. الشكل المكون من وظيفتين أعلاه يحافظ على قابلية قراءة المثال؛ قم بلف كلا الاستدعاءين في BEGIN/COMMIT عند تطبيقه.
أخيرًا، قم بتوصيل قاعدة بيانات الإنتاج وتشغيل الخادم:
// src/index.ts
import { Pool } from 'pg';
import type { QueryFn } from './db.ts';
import { buildApp } from './app.ts';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const query: QueryFn = async <R>(text: string, params: unknown[] = []) => {
const result = await pool.query(text, params);
return { rows: result.rows as R[] };
};
const app = buildApp(query);
const port = Number(process.env.PORT ?? 3000);
app.listen({ port, host: '0.0.0.0' })
.then((address) => console.log(`listening on ${address}`))
.catch((err) => {
console.error(err);
process.exit(1);
});
قم بتشغيله باستخدام محمل Node الأصلي — بدون خطوة بناء:
DATABASE_URL=postgres://postgres:secret@localhost:5432/postgres \
node --import tsx src/index.ts
التحقق
افتح نافذتين من curl. أولاً، طلب جديد — لاحظ المعرف id في الاستجابة:
curl -i -X POST http://localhost:3000/v1/payments \
-H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' \
-H 'X-Account-Id: acct_123' \
-H 'Content-Type: application/json' \
-d '{"amount":5000,"currency":"usd"}'
HTTP/1.1 201 Created
{"id":"pay_i64z416e","amount":5000,"currency":"usd","status":"succeeded"}
قم بتشغيل نفس الأمر بالضبط مرة أخرى. ستحصل على 201 مع نفس المعرف id — يتم إعادة تشغيل الاستجابة المخزنة، ولا يتم إنشاء دفعة ثانية:
HTTP/1.1 201 Created
{"id":"pay_i64z416e","amount":5000,"currency":"usd","status":"succeeded"}
الآن أعد استخدام المفتاح مع جسم طلب مختلف. تتغير البصمة، لذا يرفضها الخادم بخطأ 422:
curl -i -X POST http://localhost:3000/v1/payments \
-H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' \
-H 'X-Account-Id: acct_123' \
-H 'Content-Type: application/json' \
-d '{"amount":9999,"currency":"usd"}'
HTTP/1.1 422 Unprocessable Content
{"type":"https://API.example.com/errors/idempotency-key-reused","title":"Idempotency-Key is already used", ...}
والطلب الذي لا يحتوي على رأس Idempotency-Key يعيد 400. والمحاولة التي تصل بينما لا تزال الأولى in_progress تعيد 409. هذه هي النتائج الخمس التي تحددها المواصفات4، وكلها تدار بواسطة جدول واحد.
مقايضة التزامن التي تستحق الفهم
هناك طريقتان صادقتان للتعامل مع محاولة تصل بينما لا يزال الطلب الأول قيد التشغيل، وهما تتصرفان بشكل مختلف.
تثبيت الحجز أولاً (ما يفعله هذا الدليل): يتم إدراج صف in_progress وتثبيته قبل بدء عمل المسار. عملية INSERT ... ON CONFLICT لمحاولة متزامنة ترى صفًا مثبتًا، فتحصل فورًا على صفر صفوف، وتعيد 409. لا يتم الاحتفاظ بأي أقفال (locks) أثناء العمل البطيء. هذا هو سلوك 409 الموصى به في المواصفات وهو قابل للتوسع، ولكن يتعين على العميل المحاولة مرة أخرى بعد انتهاء الطلب الأول.
معاملة واحدة (البديل): الحجز، القيام بالعمل، ووضع علامة الاكتمال داخل معاملة واحدة. عملية INSERT ... ON CONFLICT لمحاولة متزامنة تصطدم الآن بصف غير مثبت و تتوقف حتى يتم تثبيت المعاملة الأولى، ثم ترى التعارض وتعيد تشغيل الاستجابة المنتهية — العميل لا يرى أبدًا خطأ 409. هذا أفضل للعميل، ولكنه يستهلك اتصالاً وقفل صف طوال مدة العملية، وهو أمر خطير إذا كان العمل بطيئًا أو يتصل بطرف ثالث.
اختر الطريقة الأولى لواجهات البرمجة التطبيقية العامة ذات الإنتاجية العالية؛ واختر الثانية فقط عندما تكون العمليات قصيرة ويكون الانتظار الشفاف أكثر قيمة من الإنتاجية.
انتهاء الصلاحية وتنظيف المفاتيح القديمة
المفاتيح المخزنة ليست مجانية — إذا تركت وشأنها، سيكبر الجدول للأبد. يمنح عمود expires_at كل صف عمرًا مدته 24 ساعة. قم بحذف الصفوف منتهية الصلاحية وفقًا لجدول زمني:
DELETE FROM idempotency_keys WHERE expires_at < now();
قم بتشغيله من تطبيقك على فترات منتظمة، أو ادفعه إلى قاعدة البيانات نفسها. إذا كنت تحتفظ بالفعل بمنطق cron في Postgres، فيمكنك أتمتته باستخدام pg_cron:
SELECT cron.schedule(
'reap-idempotency-keys',
'*/15 * * * *',
$$DELETE FROM idempotency_keys WHERE expires_at < now()$$
);
يتم التعامل مع الصف العالق في حالة in_progress بسبب تعطل الخادم في منتصف الطلب بواسطة نفس عملية التنظيف، ويسمح لك الطابع الزمني locked_at باستعادة مثل هذه الصفوف في وقت أقرب — تعامل مع صف in_progress الأقدم من 60 ثانية مثلاً على أنه مهجور وقم بالكتابة فوقه في المحاولة التالية.
الأخطاء الشائعة
- طلبان متزامنان يقومان بإنشاء مورد. أنت تتحقق باستخدام
SELECTثمINSERTبدلاً من جملةINSERT ... ON CONFLICTواحدة. الفجوة بين القراءة ثم الكتابة هي مكان السباق. اترك قيد التفرد (unique constraint) يقوم بتسلسل الحجز. - المحاولة تعيد
422رغم أن العميل أرسل نفس البيانات. تعتمد بصمتك على ترتيب مفاتيح JSON أو على حقل متقلب (طابع زمني، أو nonce تم إنشاؤه بواسطة العميل داخل جسم الطلب). قم بتجزئة (hash) تسلسل مستقر واستبعد الحقول التي تتغير بشكل مشروع بين المحاولات. - المفاتيح لا تنتهي صلاحيتها أبدًا والجدول يتضخم. مهمة التنظيف لا تعمل، أو لم يتم تعيين
expires_atأبدًا لأنك أدرجت صفوفًا بقائمة أعمدة تخطت القيمة الافتراضية. تأكد باستخدامSELECT min(expires_at), count(*) FROM idempotency_keys. - يحصل العملاء على خطأ
409دائم لمفتاح ما. تعطل الطلب الأول بعد الحجز ولكن قبل الإكمال أو التحرير، مما ترك صفًا عالقًا في حالةin_progress. أضف قاعدة استعادةlocked_atالمذكورة أعلاه حتى تنتهي صلاحية الحجوزات المهجورة. error: column "response_body" is of type jsonb but expression is of type text. مرر جسم الطلب إلى المشغل كسلسلة JSON نصية (JSON.stringify(body)) واترك عمودjsonbيقوم بتحليله، أو استخدم التحويل الصريح باستخدام$4::jsonb.
الخطوات التالية ومزيد من القراءة
لديك الآن طبقة "idempotency" مدعومة بـ Postgres تحول المحاولات غير الموثوقة إلى محاولات آمنة. من هنا:
- انقل تحديث
completeKeyإلى نفس المعاملة الخاصة بكتابة العمل التجاري بحيث يتم تثبيتهما بشكل ذري. - اجمع بين هذا وبين تحديد معدل الطلبات (rate limiting) باستخدام النافذة المنزلقة لـ Node API لتحصين نفس نقاط النهاية ضد سوء الاستخدام.
Footnotes
-
Fastify, npm package
fastify5.8.5; v5 targets Node.js 20 and above. https://www.npmjs.com/package/fastify ↩ -
node-postgres (
pg) 8.21.0. https://www.npmjs.com/package/pg ↩ -
PostgreSQL Global Development Group, "PostgreSQL 18.4, 17.10, 16.14, 15.18, and 14.23 Released!" 14 May 2026. https://www.postgresql.org/about/news/postgresql-184-1710-1614-1518-and-1423-released-3297/ ↩ ↩2
-
J. Jena and S. Dalal, "The Idempotency-Key HTTP Header Field," draft-ietf-httpapi-idempotency-key-header-07, IETF, 15 October 2025. https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Node.js Release Working Group, "Release schedule" (Node 24 Active LTS through April 2028). https://GitHub.com/nodejs/release ↩
-
R. Fielding et al., "HTTP Semantics," RFC 9110, June 2022 (method idempotency in §9.2.2;
422 Unprocessable Contentin §15.5.21). https://www.rfc-editor.org/rfc/rfc9110 ↩ -
Stripe, "Idempotent requests." https://docs.stripe.com/API/idempotent_requests ↩ ↩2
-
B. Leach, "Implementing Stripe-like Idempotency Keys in Postgres." https://brandur.org/idempotency-keys ↩
-
PostgreSQL documentation, "INSERT — ON CONFLICT clause." https://www.postgresql.org/docs/18/sql-insert.html ↩