From 7a79571c4c42d32ac8edfac705e8e3e8043f03cb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:01:58 +0000 Subject: [PATCH 1/3] fix: do not retry POST requests on network errors by default axios-retry's default retryCondition, isNetworkOrIdempotentRequestError, retries any network-level failure (timeout, connection reset) regardless of HTTP method. Since the SDK's write endpoints are POST requests, a connection reset or timeout mid-flight caused the same POST to be resent up to 3 times even though the server may have already received and acted on it, e.g., replaying an unlockDoor call. Restrict the default retryCondition to axios-retry's isIdempotentRequestError so only GET/HEAD/OPTIONS/PUT/DELETE are retried, matching the retry semantics already documented for the Ruby and Python SDKs. Callers can still opt back in per-request via axiosRetryOptions. Also corrects the README, which described timeout retries without noting they were previously replayed regardless of method. --- README.md | 11 ++++++ src/lib/client.ts | 13 ++++++- test/seam/connect/retry.test.ts | 65 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3bcddb92..9c378fa6 100644 --- a/README.md +++ b/README.md @@ -474,6 +474,17 @@ The Axios client and retry behavior may be configured with custom initiation opt via [`axiosOptions`][axiosOptions] and [`axiosRetryOptions`][axiosRetryOptions]. Options are deep merged with the default options. +By default, up to 2 retries are attempted for a failure that is either +a retryable status code (`429` or `5xx`) or a network-level error, +e.g., a timeout or a connection reset, +but only for HTTP methods considered idempotent (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`). +`POST` requests, e.g., most write operations against the Seam API, +are not retried by default: +since the client cannot tell whether the server received and processed +the request before the connection was lost, +automatically resending it could repeat a side effect, such as unlocking a door twice. +Pass a custom `retryCondition` in `axiosRetryOptions` to change this behavior. + [axiosOptions]: https://axios-http.com/docs/config_defaults [axiosRetryOptions]: https://github.com/softonic/axios-retry diff --git a/src/lib/client.ts b/src/lib/client.ts index ad299710..ce29fd7f 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,13 @@ export const createClient = (options: ClientOptions): AxiosInstance => { axiosRetry(client, { retries: 2, retryDelay: exponentialDelay, + // axios-retry's own default, isNetworkOrIdempotentRequestError, retries + // network errors (e.g., a connection reset or a timeout) regardless of + // HTTP method. That resends the body of an in-flight, non-idempotent + // request (e.g., a POST) whenever the client never saw a response, + // even though the server may have already received and processed it. + // Restrict retries to methods that are safe to send more than once. + retryCondition: isIdempotentRequestError, ...options.axiosRetryOptions, }) diff --git a/test/seam/connect/retry.test.ts b/test/seam/connect/retry.test.ts index d4a15510..84ad5a13 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,66 @@ 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'], + }) + + // devices.list is a POST under the hood, so a mid-flight failure here must + // not be replayed. + 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) +}) From 9675e605141d6c5a9df0d658a0131e2454bfe7ab Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 17 Aug 2026 14:00:32 -0700 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Evan Sosenko --- README.md | 10 ---------- src/lib/client.ts | 6 ------ test/seam/connect/retry.test.ts | 2 -- 3 files changed, 18 deletions(-) diff --git a/README.md b/README.md index 9c378fa6..6ff82993 100644 --- a/README.md +++ b/README.md @@ -474,16 +474,6 @@ The Axios client and retry behavior may be configured with custom initiation opt via [`axiosOptions`][axiosOptions] and [`axiosRetryOptions`][axiosRetryOptions]. Options are deep merged with the default options. -By default, up to 2 retries are attempted for a failure that is either -a retryable status code (`429` or `5xx`) or a network-level error, -e.g., a timeout or a connection reset, -but only for HTTP methods considered idempotent (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`). -`POST` requests, e.g., most write operations against the Seam API, -are not retried by default: -since the client cannot tell whether the server received and processed -the request before the connection was lost, -automatically resending it could repeat a side effect, such as unlocking a door twice. -Pass a custom `retryCondition` in `axiosRetryOptions` to change this behavior. [axiosOptions]: https://axios-http.com/docs/config_defaults [axiosRetryOptions]: https://github.com/softonic/axios-retry diff --git a/src/lib/client.ts b/src/lib/client.ts index ce29fd7f..2c0fa7f6 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -31,12 +31,6 @@ export const createClient = (options: ClientOptions): AxiosInstance => { axiosRetry(client, { retries: 2, retryDelay: exponentialDelay, - // axios-retry's own default, isNetworkOrIdempotentRequestError, retries - // network errors (e.g., a connection reset or a timeout) regardless of - // HTTP method. That resends the body of an in-flight, non-idempotent - // request (e.g., a POST) whenever the client never saw a response, - // even though the server may have already received and processed it. - // Restrict retries to methods that are safe to send more than once. retryCondition: isIdempotentRequestError, ...options.axiosRetryOptions, }) diff --git a/test/seam/connect/retry.test.ts b/test/seam/connect/retry.test.ts index 84ad5a13..95640e69 100644 --- a/test/seam/connect/retry.test.ts +++ b/test/seam/connect/retry.test.ts @@ -55,8 +55,6 @@ test('SeamHttp: does not retry POST requests by default', async (t) => { routes: ['/devices/list'], }) - // devices.list is a POST under the hood, so a mid-flight failure here must - // not be replayed. const err = await t.throwsAsync(async () => await seam.devices.list(), { instanceOf: AxiosError, }) From 72f5c2c9a02612b1d0ab819b93bcc7ef584242c0 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 17 Aug 2026 14:00:46 -0700 Subject: [PATCH 3/3] Apply suggestion from @razor-x --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 6ff82993..3bcddb92 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,6 @@ The Axios client and retry behavior may be configured with custom initiation opt via [`axiosOptions`][axiosOptions] and [`axiosRetryOptions`][axiosRetryOptions]. Options are deep merged with the default options. - [axiosOptions]: https://axios-http.com/docs/config_defaults [axiosRetryOptions]: https://github.com/softonic/axios-retry