Harden the retry classifier and pin per-attempt deadlines (closes #80)
check / check (push) Successful in 42s
check / check (push) Successful in 42s
A POST or PUT is replayed only when every errno in the cause chain is a connect errno and the walk reached the end of the chain, and postJSON/putJSON no longer follow redirects, so a redirect is an ApiError that is not retried. getRetryOptions() returns a copy. New tests pin every errno the classifier names, the cause-chain depth limit, a chain deeper than the limit, a two-error cycle, and a fresh deadline per attempt for every retrying entry point. The README endpoint list is now the one place naming the requests the replay rule covers; code comments point to it. Model: opus-5-5
This commit was merged in pull request #83.
This commit is contained in:
+14
-13
@@ -146,8 +146,9 @@ export class ApiClient {
|
||||
// The policy this client was configured with, so that a caller wrapping a
|
||||
// whole operation in its own `withRetry` — the download layer — runs under
|
||||
// the same settings rather than under the library defaults.
|
||||
// A copy, so the caller cannot change this client's settings through it.
|
||||
getRetryOptions(): ResolvedRetryOptions {
|
||||
return this.retry;
|
||||
return { ...this.retry };
|
||||
}
|
||||
|
||||
private headers(extra?: Record<string, string>): Record<string, string> {
|
||||
@@ -228,15 +229,15 @@ export class ApiClient {
|
||||
|
||||
async postJSON<T>(path: string, body: unknown): Promise<T> {
|
||||
const url = `${this.apiOrigin}${path}`;
|
||||
// Idempotency: this reaches `/users/srp/create-session`,
|
||||
// `/users/two-factor/verify` and `/users/ott`, all of which change
|
||||
// server state — verifying a second factor consumes one of a small
|
||||
// number of attempts. So a POST is replayed only on a failure that
|
||||
// establishes no TCP connection to the server ever existed: DNS
|
||||
// produced no address, or the peer refused the connection. A 5xx, a
|
||||
// mid-flight reset, a routing errno (which Linux also delivers on an
|
||||
// established socket) and a timeout are all left to the caller,
|
||||
// because each of them can occur after the server has already acted.
|
||||
// Not idempotent: a POST is replayed only when `isSafeToReplay`
|
||||
// says no request byte can have reached the server. The endpoints
|
||||
// this covers are listed in the README under "Endpoints used".
|
||||
//
|
||||
// Redirects are not followed. The origin has already received the
|
||||
// request when it answers with one, so a connection refused by the
|
||||
// redirect target would look replay-safe when it is not. The API has
|
||||
// no legitimate redirect, so one surfaces as an `ApiError` with its
|
||||
// 3xx status, which is not retried.
|
||||
return withRetry(
|
||||
async () => {
|
||||
const resp = await this._fetch(url, {
|
||||
@@ -245,6 +246,7 @@ export class ApiClient {
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
await this.throwIfError(resp);
|
||||
@@ -303,9 +305,7 @@ export class ApiClient {
|
||||
|
||||
async putJSON<T>(path: string, body: unknown): Promise<T> {
|
||||
const url = `${this.apiOrigin}${path}`;
|
||||
// Same idempotency rule as `postJSON`, for the same reason: this
|
||||
// reaches `/files/thumbnail`, which registers an uploaded thumbnail
|
||||
// against a file.
|
||||
// Same replay and redirect rules as `postJSON`, for the same reasons.
|
||||
return withRetry(
|
||||
async () => {
|
||||
const resp = await this._fetch(url, {
|
||||
@@ -314,6 +314,7 @@ export class ApiClient {
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
await this.throwIfError(resp);
|
||||
|
||||
Reference in New Issue
Block a user