diff --git a/src/lib/client.ts b/src/lib/client.ts index ad299710..2c0fa7f6 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,5 +1,9 @@ import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' -import axiosRetry, { type AxiosRetry, exponentialDelay } from 'axios-retry' +import axiosRetry, { + type AxiosRetry, + exponentialDelay, + isIdempotentRequestError, +} from 'axios-retry' import { errorInterceptor } from './error-interceptor.js' import { serializeUrlSearchParams } from './url-search-params-serializer.js' @@ -27,6 +31,7 @@ export const createClient = (options: ClientOptions): AxiosInstance => { axiosRetry(client, { retries: 2, retryDelay: exponentialDelay, + retryCondition: isIdempotentRequestError, ...options.axiosRetryOptions, }) diff --git a/test/seam/connect/retry.test.ts b/test/seam/connect/retry.test.ts index d4a15510..95640e69 100644 --- a/test/seam/connect/retry.test.ts +++ b/test/seam/connect/retry.test.ts @@ -1,3 +1,5 @@ +import { createServer } from 'node:http' + import test from 'ava' import { AxiosError } from 'axios' import { getTestServer } from 'fixtures/seam/connect/api.js' @@ -35,3 +37,64 @@ test('SeamHttp: retries 503 status errors twice by default ', async (t) => { t.is(err?.response?.status, 503) }) + +test('SeamHttp: does not retry POST requests by default', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + axiosRetryOptions: { + onRetry: () => { + t.fail('should not retry a POST request') + }, + }, + }) + + await seam.client.post('/_fake/simulate_workspace_outage', { + workspace_id: seed.seed_workspace_1, + routes: ['/devices/list'], + }) + + const err = await t.throwsAsync(async () => await seam.devices.list(), { + instanceOf: AxiosError, + }) + + t.is(err?.response?.status, 503) +}) + +test('SeamHttp: does not replay a POST after a mid-flight connection reset', async (t) => { + let attempts = 0 + + // A raw server that receives the full request and then resets the + // connection without ever sending a response, e.g., as if a load balancer + // or proxy dropped the connection after forwarding the request upstream. + const server = createServer((req) => { + attempts++ + req.resume() + req.on('end', () => { + req.socket.destroy() + }) + }) + + await new Promise((resolve) => { + server.listen(0, resolve) + }) + t.teardown(async () => { + await new Promise((resolve) => { + server.close(() => resolve()) + }) + }) + + const address = server.address() + if (address == null || typeof address === 'string') { + throw new Error('Could not determine server address') + } + + const seam = SeamHttp.fromApiKey('seam_invalidapikey_token', { + endpoint: `http://127.0.0.1:${address.port}`, + }) + + await t.throwsAsync(async () => await seam.devices.list()) + + t.is(attempts, 1) +})