fix(intake): stop a throttled sender being told their link is dead (#222)
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:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user