Clarify transfer rejections and improve storage readability

This commit is contained in:
2026-10-15 17:18:07 +09:00
parent 60a14e9c6c
commit 8fb8c203f0
17 changed files with 300 additions and 55 deletions

View File

@@ -616,25 +616,36 @@ export class Service {
input.files?.some((f) => f.size > this.admin.state.perFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
const rejected = this.db
.select()
// Count decisions for this direction only. Acceptance resets the streak;
// cancelling or letting a request expire must not bypass repeated rejections.
const decisions = this.db
.select({ status: s.requests.status, rejectedAt: s.requests.rejectedAt })
.from(s.requests)
.where(
and(
eq(s.requests.senderGuestId, g.id),
eq(s.requests.recipientGuestId, target!.guestId),
eq(s.requests.status, 'rejected'),
gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs),
or(
eq(s.requests.status, 'accepted'),
and(eq(s.requests.status, 'rejected'), sql`${s.requests.rejectedAt} IS NOT NULL`),
),
),
)
.orderBy(desc(s.requests.rejectedAt))
.get();
if (rejected)
.orderBy(desc(s.requests.createdAt), desc(sql`${s.requests}.rowid`))
.limit(3)
.all();
if (
decisions.length === 3 &&
decisions.every(
(row) =>
row.status === 'rejected' && row.rejectedAt! > Date.now() - this.cfg.rejectCooldownMs,
)
)
throw new ApiError(
429,
'REJECTION_COOLDOWN',
'상대가 거절했어요. 잠시 후 다시 시도해 주세요.',
rejected.rejectedAt! + this.cfg.rejectCooldownMs,
'상대가 연속 3회 거절했어요. 잠시 후 다시 시도해 주세요.',
decisions[0].rejectedAt! + this.cfg.rejectCooldownMs,
'pair',
);
const active = this.db

View File

@@ -339,6 +339,11 @@ test('거절, 만료, 취소 후 확정 금지와 거절 재요청 제한', asyn
).statusCode,
409,
);
for (let i = 0; i < 2; i++) {
const next = await f.request(a, b);
assert.equal(next.statusCode, 200);
await f.call(b, `/transfer-requests/${next.json().requestId}/reject`, 'POST', {});
}
assert.equal((await f.request(a, b)).statusCode, 429);
const c = await f.create();
await f.space(a, c);

View File

@@ -4,6 +4,8 @@ import { fixture } from './helpers.js';
import { Limiter, ApiError } from '../src/security.js';
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema.js';
test('a known reconnecting peer returns a transient code without creating a request or charging quotas', async () => {
const f = await fixture(true);
@@ -100,25 +102,83 @@ test('transfer limit returns Retry-After and cooldown metadata without creating
}
});
test('rejection cooldown reports time and does not use a successful-transfer quota', async () => {
test('only three consecutive recent rejections trigger a directional pair cooldown; denied retries do not charge quotas', async () => {
const f = await fixture();
try {
f.cfg.sessionRate = 2;
f.cfg.sessionRate = 4;
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
const request = await f.request(a, b);
await f.call(b, `/transfer-requests/${request.json().requestId}/reject`, 'POST', {});
for (let i = 0; i < 3; i++) {
const request = await f.request(a, b);
assert.equal(request.statusCode, 200, `rejection ${i} must allow the next request`);
const id = request.json().requestId;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode,
200,
);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode,
409,
);
}
let deadline;
for (let i = 0; i < 4; i++) {
const denied = await f.request(a, b);
assert.equal(denied.statusCode, 429);
assert.equal(denied.json().code, 'REJECTION_COOLDOWN');
assert.equal(denied.json().limitScope, 'pair');
assert(denied.json().retryAfterSeconds <= 300 && denied.json().retryAfterSeconds > 290);
deadline ??= denied.json().retryAt;
assert.equal(denied.json().retryAt, deadline, 'retry never extends the cooldown');
}
assert.equal((await f.request(a, c)).statusCode, 200);
assert.equal((await f.request(b, a)).statusCode, 200, 'reverse direction is independent');
} finally {
await f.close();
}
});
test('acceptance breaks a rejection streak; cancellation does not bypass it; cooldown expires without deleting history', async () => {
const f = await fixture(false, undefined, { pairRate: 30, sessionRate: 50 });
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const reject = async () => {
const request = await f.request(a, b);
assert.equal(request.statusCode, 200, request.body);
const id = request.json().requestId;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode,
200,
);
return id;
};
await reject();
await reject();
await f.complete(a, b, '수락하면 누적 초기화');
await reject();
await reject();
const cancelled = await f.request(a, b);
assert.equal(cancelled.statusCode, 200);
await f.call(a, `/transfer-requests/${cancelled.json().requestId}/cancel`, 'POST', {});
const id = await reject();
assert.equal((await f.request(a, b)).json().code, 'REJECTION_COOLDOWN');
const cutoff = Date.now() - f.cfg.rejectCooldownMs;
// At exactly the third rejection's deadline the same history no longer blocks.
f.service.db
.update(schema.requests)
.set({ rejectedAt: cutoff })
.where(eq(schema.requests.id, id))
.run();
assert.equal((await f.request(a, b)).statusCode, 200);
assert.equal(
f.service.db.select().from(schema.requests).where(eq(schema.requests.id, id)).get().status,
'rejected',
);
} finally {
await f.close();
}