Repository navigation
fix(api): replay a gateway error only for reads, so a write is never sent twice #1891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,12 +1,36 @@ | ||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Reads, by Harper's operation naming: the restart gate holds only these (anything else is refused | ||||||||||||||||||||||||||||
| * while the entity restarts), and the operations console asks before sending anything else. Token | ||||||||||||||||||||||||||||
| * minting is deliberately not one — held, it would stall a route guard's Fabric Connect attempt behind | ||||||||||||||||||||||||||||
| * a notice that cannot mount yet; refused, the guard falls through to sign-in at once. | ||||||||||||||||||||||||||||
| * while the entity restarts), the gateway retry replays only these, and the operations console asks | ||||||||||||||||||||||||||||
| * before sending anything else. Token minting is deliberately not one — held, it would stall a route | ||||||||||||||||||||||||||||
| * guard's Fabric Connect attempt behind a notice that cannot mount yet; refused, the guard falls | ||||||||||||||||||||||||||||
| * through to sign-in at once. | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| const READ_OPERATION = | ||||||||||||||||||||||||||||
| /^(?:get_|search_|describe_|list_|read_|user_info$|system_information$|cluster_status$|registration_info$)/; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export function isReadOperation(operation: unknown): boolean { | ||||||||||||||||||||||||||||
| return typeof operation === 'string' && READ_OPERATION.test(operation); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| function operationOf(data: unknown): unknown { | ||||||||||||||||||||||||||||
| if (typeof data !== 'string') { | ||||||||||||||||||||||||||||
| return (data as { operation?: unknown } | undefined)?.operation; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| // By the time a request has been sent, axios has serialized `config.data` in place; the operations | ||||||||||||||||||||||||||||
| // console also sends raw text. | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| return (JSON.parse(data) as { operation?: unknown } | null)?.operation; | ||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Whether a request is safe to send late (the restart gate) or twice (the gateway retry); anything | ||||||||||||||||||||||||||||
| * unrecognized counts as a write. Harper and central manager's proxy take an operation only from a | ||||||||||||||||||||||||||||
| * POST body, so on any other method an `operation` field is just data. | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| export function isReadRequest({ method, data }: { method?: unknown; data?: unknown }): boolean { | ||||||||||||||||||||||||||||
| const verb = typeof method === 'string' ? method.toLowerCase() : undefined; | ||||||||||||||||||||||||||||
| return verb === 'get' || (verb === 'post' && isReadOperation(operationOf(data))); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of
We can make this more robust by defaulting
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Point 2 fixed in c2dc659: only a POST body's Point 1 declined. A missing method never reaches either caller: axios sets 🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth. |
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| /** @vitest-environment jsdom */ | ||
| import { getInstanceClient } from '@/config/getInstanceClient'; | ||
| import * as sleepModule from '@/lib/sleep'; | ||
| import { AxiosError, type InternalAxiosRequestConfig } from 'axios'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| vi.mock('@/lib/sleep', () => ({ | ||
| sleep: vi.fn().mockResolvedValue(true), | ||
| })); | ||
|
|
||
| const OPERATIONS_URL = 'https://ins-123.example.com:9925/'; | ||
|
|
||
| /** | ||
| * A target behind a gateway that answers `statuses` in turn, then 200. Axios applies | ||
| * `validateStatus` inside its own adapters, so this one rejects a non-2xx itself. | ||
| */ | ||
| function gateway(...statuses: number[]) { | ||
| const sent: Array<{ method?: string; body: unknown }> = []; | ||
| const client = getInstanceClient({ id: 'ins-123', operationsUrl: OPERATIONS_URL }); | ||
| client.defaults.adapter = async (config: InternalAxiosRequestConfig) => { | ||
| sent.push({ method: config.method, body: typeof config.data === 'string' ? JSON.parse(config.data) : config.data }); | ||
| const status = statuses.shift() ?? 200; | ||
| const response = { status, statusText: '', data: { status }, headers: {}, config }; | ||
| if (status >= 400) { | ||
| throw new AxiosError( | ||
| `Request failed with status code ${status}`, | ||
| AxiosError.ERR_BAD_RESPONSE, | ||
| config, | ||
| undefined, | ||
| response, | ||
| ); | ||
| } | ||
| return response; | ||
| }; | ||
| return { client, sent }; | ||
| } | ||
|
|
||
| function statusOf(error: unknown): number | undefined { | ||
| return (error as AxiosError).response?.status; | ||
| } | ||
|
|
||
| describe('the gateway retry on a real instance client', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it.each([502, 503, 504])('sends a write once and surfaces its %i', async (status) => { | ||
| const { client, sent } = gateway(status, status, status, status); | ||
|
|
||
| const error = await client.post('/', { operation: 'insert', records: [{ name: 'a' }] }).catch((e: unknown) => e); | ||
|
|
||
| expect(statusOf(error)).toBe(status); | ||
| expect(sent).toEqual([{ method: 'post', body: { operation: 'insert', records: [{ name: 'a' }] } }]); | ||
| expect(sleepModule.sleep).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it.each(['drop_table', 'deploy_component', 'set_configuration', 'restart', 'create_authentication_tokens'])( | ||
| 'never replays %s', | ||
| async (operation) => { | ||
| const { client, sent } = gateway(504); | ||
|
|
||
| await expect(client.post('/', { operation })).rejects.toBeInstanceOf(AxiosError); | ||
| expect(sent).toHaveLength(1); | ||
| }, | ||
| ); | ||
|
|
||
| it.each([502, 503, 504])('replays a read three times on a %i, 5s/10s/20s apart, as before', async (status) => { | ||
| const { client, sent } = gateway(status, status, status, status); | ||
|
|
||
| const error = await client.post('/', { operation: 'describe_all' }).catch((e: unknown) => e); | ||
|
|
||
| expect(statusOf(error)).toBe(status); | ||
| expect(sent).toHaveLength(4); | ||
| expect(sent.every(({ body }) => (body as { operation: string }).operation === 'describe_all')).toBe(true); | ||
| expect(vi.mocked(sleepModule.sleep).mock.calls).toEqual([[5_000], [10_000], [20_000]]); | ||
| }); | ||
|
|
||
| it('returns the answer to a read that recovers on its replay', async () => { | ||
| const { client, sent } = gateway(503); | ||
|
|
||
| const { data } = await client.post('/', { operation: 'get_job', id: 'job-1' }); | ||
|
|
||
| expect(data).toEqual({ status: 200 }); | ||
| expect(sent).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('replays a GET', async () => { | ||
| const { client, sent } = gateway(502, 502); | ||
|
|
||
| await expect(client.get('/health')).resolves.toMatchObject({ status: 200 }); | ||
| expect(sent).toHaveLength(3); | ||
| }); | ||
|
|
||
| it('sends a read that opted out once', async () => { | ||
| const { client, sent } = gateway(503); | ||
|
|
||
| await expect(client.post('/', { operation: 'describe_all' }, { skipGatewayRetry: true })).rejects | ||
| .toBeInstanceOf(AxiosError); | ||
| expect(sent).toHaveLength(1); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a test case to verify that
isReadRequestcorrectly defaults to'get'when themethodproperty is omitted/undefined.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declined. Axios always sets
config.methodbefore interceptors run (axios 1.20.0lib/core/Axios.js:148-152), so no real request reachesisReadRequestwithout one. Where a config does lack it, treating it as a read would break the predicate's rule that anything unrecognized is a write. c2dc659 pins the opposite instead:a read body with no methodis in the write cases.🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.