|
1 | 1 | import type { RetryErrorInfo } from "@smithy/types"; |
2 | | -import { afterEach, describe, expect, test as it, vi } from "vitest"; |
| 2 | +import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; |
3 | 3 |
|
4 | 4 | import { RETRY_MODES } from "./config"; |
| 5 | +import { MAXIMUM_RETRY_DELAY } from "./constants"; |
| 6 | +import { DefaultRetryBackoffStrategy } from "./DefaultRetryBackoffStrategy"; |
5 | 7 | import { DefaultRetryToken } from "./DefaultRetryToken"; |
6 | 8 | import { Retry } from "./retries-2026-config"; |
7 | 9 | import { StandardRetryStrategy } from "./StandardRetryStrategy"; |
8 | 10 |
|
| 11 | +class DeterministicRetryBackoffStrategy extends DefaultRetryBackoffStrategy { |
| 12 | + public computeNextBackoffDelay(i: number): number { |
| 13 | + const b = 1; // maximum instead of Math.random() |
| 14 | + const r = 2; |
| 15 | + const t_i = b * Math.min(this.x * r ** i, MAXIMUM_RETRY_DELAY); |
| 16 | + return Math.floor(t_i); |
| 17 | + } |
| 18 | +} |
| 19 | + |
9 | 20 | vi.mock("./DefaultRetryToken"); |
10 | 21 |
|
11 | 22 | describe(StandardRetryStrategy.name, () => { |
@@ -139,4 +150,186 @@ describe(StandardRetryStrategy.name, () => { |
139 | 150 | } |
140 | 151 | }); |
141 | 152 | }); |
| 153 | + |
| 154 | + describe("retryCode", () => { |
| 155 | + it("returns code 1 (non-retryable) with highest priority over other reasons", async () => { |
| 156 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 157 | + () => |
| 158 | + ({ |
| 159 | + getRetryCount: () => 5, |
| 160 | + getRetryCost: () => 0, |
| 161 | + getRetryDelay: () => 0, |
| 162 | + }) as any |
| 163 | + ); |
| 164 | + const retryStrategy = new StandardRetryStrategy(1); |
| 165 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 166 | + // non-retryable + attempts exhausted: should get code 1 (non-retryable wins) |
| 167 | + const result = retryStrategy["retryCode"](token, { errorType: "CLIENT_ERROR" } as RetryErrorInfo, 1); |
| 168 | + expect(result).toBe(1); |
| 169 | + }); |
| 170 | + |
| 171 | + it("returns code 2 (attempts exhausted) when retryable but no attempts left", async () => { |
| 172 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 173 | + () => |
| 174 | + ({ |
| 175 | + getRetryCount: () => 2, |
| 176 | + getRetryCost: () => 0, |
| 177 | + getRetryDelay: () => 0, |
| 178 | + }) as any |
| 179 | + ); |
| 180 | + const retryStrategy = new StandardRetryStrategy(maxAttempts); |
| 181 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 182 | + const result = retryStrategy["retryCode"](token, errorInfo, maxAttempts); |
| 183 | + expect(result).toBe(2); |
| 184 | + }); |
| 185 | + |
| 186 | + it("returns code 3 (no capacity) when retryable with attempts left but no tokens", async () => { |
| 187 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 188 | + () => |
| 189 | + ({ |
| 190 | + getRetryCount: () => 0, |
| 191 | + getRetryCost: () => 0, |
| 192 | + getRetryDelay: () => 0, |
| 193 | + }) as any |
| 194 | + ); |
| 195 | + const retryStrategy = new StandardRetryStrategy(maxAttempts); |
| 196 | + retryStrategy["capacity"] = 0; |
| 197 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 198 | + const result = retryStrategy["retryCode"](token, errorInfo, maxAttempts); |
| 199 | + expect(result).toBe(3); |
| 200 | + }); |
| 201 | + |
| 202 | + it("returns code 0 (OK to retry) when all conditions are met", async () => { |
| 203 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 204 | + () => |
| 205 | + ({ |
| 206 | + getRetryCount: () => 0, |
| 207 | + getRetryCost: () => 0, |
| 208 | + getRetryDelay: () => 0, |
| 209 | + }) as any |
| 210 | + ); |
| 211 | + const retryStrategy = new StandardRetryStrategy(maxAttempts); |
| 212 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 213 | + const result = retryStrategy["retryCode"](token, errorInfo, maxAttempts); |
| 214 | + expect(result).toBe(0); |
| 215 | + }); |
| 216 | + }); |
| 217 | + |
| 218 | + describe("long-poll token behavior", () => { |
| 219 | + it("throws with $backoff when long-poll token has no capacity (code 3)", async () => { |
| 220 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 221 | + () => |
| 222 | + ({ |
| 223 | + getRetryCount: () => 0, |
| 224 | + getRetryCost: () => 0, |
| 225 | + getRetryDelay: () => 0, |
| 226 | + isLongPoll: () => true, |
| 227 | + }) as any |
| 228 | + ); |
| 229 | + const retryStrategy = new StandardRetryStrategy(maxAttempts); |
| 230 | + retryStrategy["capacity"] = 0; |
| 231 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 232 | + try { |
| 233 | + await retryStrategy.refreshRetryTokenForRetry(token, errorInfo); |
| 234 | + fail("expected error"); |
| 235 | + } catch (error: any) { |
| 236 | + expect(error.message).toBe("No retry token available"); |
| 237 | + // $backoff is 0 when Retry.v2026 is false, non-zero when true |
| 238 | + expect(error.$backoff).toBe(0); |
| 239 | + } |
| 240 | + }); |
| 241 | + |
| 242 | + it("throws with $backoff=0 when long-poll token fails for non-capacity reason (code 1)", async () => { |
| 243 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 244 | + () => |
| 245 | + ({ |
| 246 | + getRetryCount: () => 0, |
| 247 | + getRetryCost: () => 0, |
| 248 | + getRetryDelay: () => 0, |
| 249 | + isLongPoll: () => true, |
| 250 | + }) as any |
| 251 | + ); |
| 252 | + const retryStrategy = new StandardRetryStrategy(maxAttempts); |
| 253 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 254 | + try { |
| 255 | + await retryStrategy.refreshRetryTokenForRetry(token, { errorType: "CLIENT_ERROR" } as RetryErrorInfo); |
| 256 | + fail("expected error"); |
| 257 | + } catch (error: any) { |
| 258 | + expect(error.message).toBe("No retry token available"); |
| 259 | + expect(error.$backoff).toBe(0); |
| 260 | + } |
| 261 | + }); |
| 262 | + |
| 263 | + it("retries long-poll token even when retryCode is non-zero, if capacity exists", async () => { |
| 264 | + const getRetryCount = vi.fn().mockReturnValue(0); |
| 265 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 266 | + () => |
| 267 | + ({ |
| 268 | + getRetryCount, |
| 269 | + getRetryCost: () => 0, |
| 270 | + getRetryDelay: () => 0, |
| 271 | + isLongPoll: () => true, |
| 272 | + }) as any |
| 273 | + ); |
| 274 | + const retryStrategy = new StandardRetryStrategy(maxAttempts); |
| 275 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 276 | + const refreshed = await retryStrategy.refreshRetryTokenForRetry(token, errorInfo); |
| 277 | + expect(refreshed).toBeDefined(); |
| 278 | + }); |
| 279 | + |
| 280 | + describe("with Retry.v2026 enabled", () => { |
| 281 | + let originalV2026: boolean; |
| 282 | + |
| 283 | + beforeEach(() => { |
| 284 | + originalV2026 = Retry.v2026; |
| 285 | + Retry.v2026 = true; |
| 286 | + }); |
| 287 | + |
| 288 | + afterEach(() => { |
| 289 | + Retry.v2026 = originalV2026; |
| 290 | + }); |
| 291 | + |
| 292 | + it("throws with non-zero $backoff for code 3", async () => { |
| 293 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 294 | + () => |
| 295 | + ({ |
| 296 | + getRetryCount: () => 0, |
| 297 | + getRetryCost: () => 0, |
| 298 | + getRetryDelay: () => 0, |
| 299 | + isLongPoll: () => true, |
| 300 | + }) as any |
| 301 | + ); |
| 302 | + const retryStrategy = new StandardRetryStrategy({ |
| 303 | + maxAttempts, |
| 304 | + backoff: new DeterministicRetryBackoffStrategy(), |
| 305 | + }); |
| 306 | + retryStrategy["capacity"] = 0; |
| 307 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 308 | + await expect(retryStrategy.refreshRetryTokenForRetry(token, errorInfo)).rejects.toMatchObject({ |
| 309 | + message: "No retry token available", |
| 310 | + $backoff: 50, // b=1 * min(50 * 2^0, 20000) = 50 |
| 311 | + }); |
| 312 | + }); |
| 313 | + |
| 314 | + it("throws with $backoff=0 for non-capacity code", async () => { |
| 315 | + vi.mocked(DefaultRetryToken).mockImplementation( |
| 316 | + () => |
| 317 | + ({ |
| 318 | + getRetryCount: () => 0, |
| 319 | + getRetryCost: () => 0, |
| 320 | + getRetryDelay: () => 0, |
| 321 | + isLongPoll: () => true, |
| 322 | + }) as any |
| 323 | + ); |
| 324 | + const retryStrategy = new StandardRetryStrategy(maxAttempts); |
| 325 | + const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope); |
| 326 | + await expect( |
| 327 | + retryStrategy.refreshRetryTokenForRetry(token, { errorType: "CLIENT_ERROR" } as RetryErrorInfo) |
| 328 | + ).rejects.toMatchObject({ |
| 329 | + message: "No retry token available", |
| 330 | + $backoff: 0, |
| 331 | + }); |
| 332 | + }); |
| 333 | + }); |
| 334 | + }); |
142 | 335 | }); |
0 commit comments