fix(api)!: always send Retry-After and reclassify two limits (#2489)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:07:00 +02:00
1 parent bec34ea147
commit bff29d8f07
9 files changed
+202 -18

No files matched your search

@@ -77,7 +77,7 @@ describe('Auth IP Authorization Resend', () => {
.execute();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
});
it('returns resend_available_in when rate limited', async () => {
it('returns resend_available_in and a matching Retry-After header when rate limited', async () => {
const email = createUniqueEmail('ip-resend-cooldown');
const password = 'a-strong-password';
await registerUser(harness, {
@@ -90,15 +90,17 @@ describe('Auth IP Authorization Resend', () => {
});
const newIP = '10.230.240.250';
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
const {response, json: errorResp} = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
.executeWithResponse();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
expect(typeof errorResp.resend_available_in).toBe('number');
expect(errorResp.resend_available_in).toBeGreaterThan(0);
expect(response.headers.get('Retry-After')).toBe(String(errorResp.resend_available_in));
expect(errorResp.retry_after).toBe(errorResp.resend_available_in);
});
});
describe('Multiple resend attempts handling', () => {
@@ -126,7 +128,7 @@ describe('Auth IP Authorization Resend', () => {
});
});
describe('Resend with already-used ticket', () => {
it('returns 429 when resend flag is already set on the ticket', async () => {
it('returns 400 when resend flag is already set on the ticket', async () => {
const email = createUniqueEmail('ip-resend-used');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
@@ -155,14 +157,15 @@ describe('Auth IP Authorization Resend', () => {
ttl_seconds: 900,
})
.execute();
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
const {response, json: errorResp} = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket})
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED);
expect(response.headers.get('Retry-After')).toBeNull();
});
it('returns 429 after IP has already been authorized', async () => {
it('returns 400 after IP has already been authorized', async () => {
const email = createUniqueEmail('ip-resend-after-auth');
const password = 'a-strong-password';
await registerUser(harness, {
@@ -304,7 +307,7 @@ describe('Auth IP Authorization Resend', () => {
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket})
.expect(HTTP_TOO_MANY_REQUESTS)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED);
expect(typeof errorResp.message).toBe('string');
@@ -230,7 +230,7 @@ export class MessageEditService {
if (!lockToken) {
throw new ThrottledError({
code: APIErrorCodes.RESOURCE_LOCKED,
headers: {'Retry-After': '1'},
retryAfterSeconds: 1,
data: {retry_after: 1},
});
}
@@ -2,9 +2,9 @@
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {MAX_CONNECTIONS_PER_USER} from '@fluxer/constants/src/ConnectionConstants';
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
export class ConnectionLimitReachedError extends ThrottledError {
export class ConnectionLimitReachedError extends BadRequestError {
constructor(limit: number = MAX_CONNECTIONS_PER_USER) {
super({
code: APIErrorCodes.CONNECTION_LIMIT_REACHED,
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
ConnectionTypes,
ConnectionVisibilityFlags,
MAX_CONNECTIONS_PER_USER,
} from '@fluxer/constants/src/ConnectionConstants';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {createUserID, type UserID} from '../../BrandedTypes';
import type {IBlueskyOAuthService} from '../../bluesky/IBlueskyOAuthService';
import {setCassandraQueryExecutorForTesting} from '../../database/CassandraQueryExecution';
import type {UserConnectionRow} from '../../database/types/ConnectionTypes';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import {InMemoryCassandraQueryExecutor} from '../../test/InMemoryCassandraQueryExecutor';
import {ConnectionRepository} from '../ConnectionRepository';
import {ConnectionService} from '../ConnectionService';
import {ConnectionLimitReachedError} from '../errors/ConnectionLimitReachedError';
function connectionRow(userId: UserID, connectionId: string, sortOrder: number): UserConnectionRow {
return {
user_id: userId,
connection_id: connectionId,
connection_type: ConnectionTypes.DOMAIN,
identifier: `${connectionId}.example`,
name: connectionId,
verified: true,
visibility_flags: ConnectionVisibilityFlags.EVERYONE,
sort_order: sortOrder,
verification_token: 'token',
verified_at: null,
last_verified_at: null,
created_at: new Date(0),
version: 1,
};
}
describe('ConnectionService connection limit', () => {
let executor: InMemoryCassandraQueryExecutor;
beforeEach(() => {
executor = new InMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
});
afterEach(() => {
executor.reset();
setCassandraQueryExecutorForTesting(null);
});
it('rejects the connection past the ceiling with 400 and no retry guidance', async () => {
const userId = createUserID(1n);
const repository = new ConnectionRepository();
for (let index = 0; index < MAX_CONNECTIONS_PER_USER; index++) {
await repository.create(connectionRow(userId, `connection-${index}`, index));
}
const service = new ConnectionService(
repository,
{} as unknown as IGatewayService,
{} as unknown as IBlueskyOAuthService,
);
const error = await service.initiateConnection(userId, ConnectionTypes.DOMAIN, 'over-the-limit.example').then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(ConnectionLimitReachedError);
const response = (error as ConnectionLimitReachedError).getResponse();
expect(response.status).toBe(400);
expect(response.headers.get('Retry-After')).toBeNull();
expect(await response.json()).toMatchObject({code: APIErrorCodes.CONNECTION_LIMIT_REACHED});
});
it('allows a connection while a slot is free', async () => {
const userId = createUserID(2n);
const repository = new ConnectionRepository();
for (let index = 0; index < MAX_CONNECTIONS_PER_USER - 1; index++) {
await repository.create(connectionRow(userId, `connection-${index}`, index));
}
const service = new ConnectionService(
repository,
{} as unknown as IGatewayService,
{} as unknown as IBlueskyOAuthService,
);
await expect(
service.initiateConnection(userId, ConnectionTypes.DOMAIN, 'under-the-limit.example'),
).resolves.toEqual({});
});
});