|
15 | 15 | import software.amazon.lambda.durable.DurableContext; |
16 | 16 | import software.amazon.lambda.durable.TypeToken; |
17 | 17 | import software.amazon.lambda.durable.config.WithRetryConfig; |
| 18 | +import software.amazon.lambda.durable.exception.SerDesException; |
18 | 19 | import software.amazon.lambda.durable.exception.UnrecoverableDurableExecutionException; |
19 | 20 | import software.amazon.lambda.durable.execution.SuspendExecutionException; |
20 | 21 | import software.amazon.lambda.durable.retry.RetryDecision; |
@@ -180,6 +181,19 @@ void nullConfig_shouldThrow() { |
180 | 181 | NullPointerException.class, |
181 | 182 | () -> WithRetryHelper.retryOperation(context, "name", (ctx, a) -> "x", null)); |
182 | 183 | } |
| 184 | + |
| 185 | + @Test |
| 186 | + void operationReturnsNull() { |
| 187 | + var config = WithRetryConfig.builder() |
| 188 | + .retryStrategy(RetryStrategies.Presets.NO_RETRY) |
| 189 | + .wrapInChildContext(false) |
| 190 | + .build(); |
| 191 | + |
| 192 | + var result = WithRetryHelper.retryOperation( |
| 193 | + context, "my-op", (WithRetry<String>) (ctx, attempt) -> null, config); |
| 194 | + |
| 195 | + assertNull(result); |
| 196 | + } |
183 | 197 | } |
184 | 198 |
|
185 | 199 | // --- Anonymous form tests --- |
@@ -278,6 +292,38 @@ void nullConfig_shouldThrow() { |
278 | 292 | assertThrows( |
279 | 293 | NullPointerException.class, () -> WithRetryHelper.retryOperation(context, (ctx, a) -> "x", null)); |
280 | 294 | } |
| 295 | + |
| 296 | + @Test |
| 297 | + void operationReturnsNull() { |
| 298 | + var config = WithRetryConfig.builder() |
| 299 | + .retryStrategy(RetryStrategies.Presets.NO_RETRY) |
| 300 | + .build(); |
| 301 | + |
| 302 | + var result = WithRetryHelper.retryOperation(context, (WithRetry<String>) (ctx, attempt) -> null, config); |
| 303 | + |
| 304 | + assertNull(result); |
| 305 | + } |
| 306 | + |
| 307 | + @Test |
| 308 | + void usesDefaultDelayWhenRetryDecisionDelayIsZero() { |
| 309 | + var config = WithRetryConfig.builder() |
| 310 | + .retryStrategy( |
| 311 | + (error, attempt) -> attempt < 2 ? RetryDecision.retry(Duration.ZERO) : RetryDecision.fail()) |
| 312 | + .build(); |
| 313 | + |
| 314 | + var result = WithRetryHelper.retryOperation( |
| 315 | + context, |
| 316 | + (ctx, attempt) -> { |
| 317 | + if (attempt == 1) { |
| 318 | + throw new RuntimeException("fail"); |
| 319 | + } |
| 320 | + return "ok"; |
| 321 | + }, |
| 322 | + config); |
| 323 | + |
| 324 | + assertEquals("ok", result); |
| 325 | + verify(context).wait("retry-backoff-1", Duration.ofSeconds(1)); |
| 326 | + } |
281 | 327 | } |
282 | 328 |
|
283 | 329 | // --- Retry behavior tests --- |
@@ -455,5 +501,73 @@ void propagatesUnrecoverableDurableExecutionExceptionWithoutRetrying() { |
455 | 501 |
|
456 | 502 | verify(context, never()).wait(anyString(), any(Duration.class)); |
457 | 503 | } |
| 504 | + |
| 505 | + @Test |
| 506 | + void propagatesSuspendExecutionExceptionOnLaterAttempt() { |
| 507 | + var config = WithRetryConfig.builder() |
| 508 | + .retryStrategy((error, attempt) -> RetryDecision.retry(Duration.ofSeconds(1))) |
| 509 | + .build(); |
| 510 | + |
| 511 | + assertThrows( |
| 512 | + SuspendExecutionException.class, |
| 513 | + () -> WithRetryHelper.retryOperation( |
| 514 | + context, |
| 515 | + (ctx, attempt) -> { |
| 516 | + if (attempt == 1) { |
| 517 | + throw new RuntimeException("transient"); |
| 518 | + } |
| 519 | + // Second attempt triggers suspend — must propagate, not retry |
| 520 | + throw new SuspendExecutionException(); |
| 521 | + }, |
| 522 | + config)); |
| 523 | + |
| 524 | + // First attempt retried (one backoff wait), second attempt suspended immediately |
| 525 | + verify(context, times(1)).wait(anyString(), any(Duration.class)); |
| 526 | + } |
| 527 | + |
| 528 | + @Test |
| 529 | + void propagatesUnrecoverableDurableExecutionExceptionOnLaterAttempt() { |
| 530 | + var config = WithRetryConfig.builder() |
| 531 | + .retryStrategy((error, attempt) -> RetryDecision.retry(Duration.ofSeconds(1))) |
| 532 | + .build(); |
| 533 | + |
| 534 | + assertThrows( |
| 535 | + UnrecoverableDurableExecutionException.class, |
| 536 | + () -> WithRetryHelper.retryOperation( |
| 537 | + context, |
| 538 | + (ctx, attempt) -> { |
| 539 | + if (attempt == 1) { |
| 540 | + throw new RuntimeException("transient"); |
| 541 | + } |
| 542 | + throw new UnrecoverableDurableExecutionException( |
| 543 | + software.amazon.awssdk.services.lambda.model.ErrorObject.builder() |
| 544 | + .errorMessage("unrecoverable on attempt 2") |
| 545 | + .build()); |
| 546 | + }, |
| 547 | + config)); |
| 548 | + |
| 549 | + verify(context, times(1)).wait(anyString(), any(Duration.class)); |
| 550 | + } |
| 551 | + |
| 552 | + @Test |
| 553 | + void preservesCheckedExceptionSubclassType() { |
| 554 | + var config = WithRetryConfig.builder() |
| 555 | + .retryStrategy(RetryStrategies.Presets.NO_RETRY) |
| 556 | + .build(); |
| 557 | + |
| 558 | + var original = new SerDesException("deserialization failed", new RuntimeException("bad json")); |
| 559 | + |
| 560 | + var thrown = assertThrows( |
| 561 | + SerDesException.class, |
| 562 | + () -> WithRetryHelper.retryOperation( |
| 563 | + context, |
| 564 | + (ctx, attempt) -> { |
| 565 | + throw original; |
| 566 | + }, |
| 567 | + config)); |
| 568 | + |
| 569 | + assertSame(original, thrown); |
| 570 | + assertEquals("deserialization failed", thrown.getMessage()); |
| 571 | + } |
458 | 572 | } |
459 | 573 | } |
0 commit comments