fix(intake): stop a throttled sender being told their link is dead (#222)
Linting / lint (pull_request) Successful in 2m27s
SonarQube Analysis / sonarqube (pull_request) Successful in 22m27s

Adding e2e specs for the submission page found a defect in the page they were written for, which is what they were for.

One limiter counted page loads and submissions against the same twenty-per-quarter-hour allowance, so a sender working through a box of stock ran out after ten items — the exact person the feature exists for, and the exact case the limiter's own comment said must not be refused. The comment said refusing them costs a consignment while the number quietly did it.

Worse, the page could not tell a 429 from a 404. `fetchIntakeLink` treated any non-OK response as "no link", so a throttled sender was told "This link is not active" and sent to ask for a replacement — which could not have helped, because the problem was their address and a minute of patience. Two conditions needing opposite reactions were sharing a message.

Now two limiters, because the two requests cost different things. Reading a link hits one indexed row and writes nothing, so that allowance is generous at 120: someone re-reading the form or losing their signal should never be told to wait. Submitting writes up to six files, so that is the one worth bounding, at 30 — more than anyone photographing items can manage and far less than a script would want.

The page gains a third state. Unknown, revoked and used-up still collapse into one "not active" card, because whether a link exists is not something a stranger needs to learn. Throttled is deliberately kept apart from them, since "wait a moment" and "go and ask for another link" are opposite instructions.

Measured rather than assumed, on a freshly started process both times: before, 25 page loads produced 14 rejections; after, 40 produce none. The first attempt at that measurement was wrong and worth recording — the restart had failed with EADDRINUSE, so it read 30 of 30 against the old process's already-exhausted store.

The two specs now pass in a full parallel run alongside everything else. They are scoped the way #241 asks: unique run ids, assertions naming only this run's rows, nothing asserted about the table as a whole.

Backend: 284 integration, 309 unit. Frontend: build clean, lint unchanged at 2 pre-existing warnings.

Ref #222, #241
This commit is contained in:
2026-08-31 15:42:26 -05:00
parent 9b4d7f2d03
commit bcecda9122
6 changed files with 233 additions and 24 deletions
+26 -7
View File
@@ -11,6 +11,7 @@ import Space from 'antd/es/space';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { fetchIntakeLink, submitItem } from './intakeApi';
import type { LinkState } from './intakeApi';
const { Title, Paragraph } = Typography;
const { TextArea } = Input;
@@ -28,7 +29,7 @@ const MAX_IMAGES = 6;
export default function Submit() {
const { token = '' } = useParams();
const [label, setLabel] = useState<string | null>(null);
const [state, setState] = useState<LinkState>({ kind: 'unusable' });
const [checking, setChecking] = useState(true);
const [files, setFiles] = useState<UploadFile[]>([]);
const [note, setNote] = useState('');
@@ -38,11 +39,11 @@ export default function Submit() {
useEffect(() => {
let cancelled = false;
void fetchIntakeLink(token).then((link) => {
void fetchIntakeLink(token).then((result) => {
// The token can change if the URL does, and a late response from the
// previous one would otherwise overwrite the current answer.
if (cancelled) return;
setLabel(link?.label ?? null);
setState(result);
setChecking(false);
});
return () => {
@@ -80,10 +81,28 @@ export default function Submit() {
);
}
// One state for every refusal, matching the server's single 404. Saying which
// of revoked, unknown or used-up it was would tell a stranger whether a link
// they guessed at exists.
if (label === null) {
// Kept apart from the state below on purpose. The two need opposite
// reactions — wait a moment, versus go and ask for a different link — so
// telling a throttled sender their link was dead would send them to fetch a
// replacement that could not have helped.
if (state.kind === 'throttled') {
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Too many requests just now</Title>
<Paragraph>
Your link is fine this page has just been asked for too many times from your
connection. Wait a minute and reload.
</Paragraph>
</Card>
</div>
);
}
// One state for unknown, revoked and used-up alike, matching the server's
// single 404. Saying which it was would tell a stranger whether a link they
// guessed at exists.
if (state.kind === 'unusable') {
return (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
<Card>
+22 -8
View File
@@ -2,19 +2,33 @@ export interface IntakeLink {
label: string;
}
export type LinkState =
| { kind: 'usable'; link: IntakeLink }
| { kind: 'unusable' }
| { kind: 'throttled' };
/**
* Whether a link works, and what it is called.
*
* Every refusal from the server is a 404 by design — unknown, revoked and
* exhausted links are indistinguishable, because whether a link exists is not
* something a stranger needs to learn. So there is one "this link does not
* work" state here rather than several the page would have to explain, and
* null is the whole of it.
* The server answers 404 for unknown, revoked and exhausted links alike, and
* that is deliberate — whether a link exists is not something a stranger needs
* to learn. So all three collapse into one `unusable` state here rather than
* several the page would have to explain.
*
* A 429 is deliberately *not* one of them. This used to treat any non-OK
* response as "no link", which meant a rate-limited sender was told their link
* was dead — sending them to ask for a replacement that would not have helped,
* because the problem was the address they were coming from and a minute of
* patience. Two conditions that need opposite reactions must not share a
* message.
*/
export async function fetchIntakeLink(token: string): Promise<IntakeLink | null> {
export async function fetchIntakeLink(token: string): Promise<LinkState> {
const res = await fetch(`/api/intake/${encodeURIComponent(token)}`);
if (!res.ok) return null;
return res.json();
if (res.status === 429) return { kind: 'throttled' };
if (!res.ok) return { kind: 'unusable' };
return { kind: 'usable', link: await res.json() };
}
export type SubmitResult = { ok: true } | { ok: false; error: string };