From 1b50cb4f5820b3d634d1618b5930e4e12cd29399 Mon Sep 17 00:00:00 2001 From: Doksanbir Date: Thu, 3 Sep 2026 12:18:29 +0300 Subject: [PATCH 1/2] feat: add Timeout pattern (#2845) --- pom.xml | 1 + timeout/README.md | 287 ++++++++++++++++++ timeout/etc/timeout.urm.puml | 65 ++++ timeout/pom.xml | 70 +++++ .../main/java/com/iluwatar/timeout/App.java | 113 +++++++ .../timeout/ProductCatalogService.java | 63 ++++ .../timeout/RecommendationService.java | 73 +++++ .../timeout/ServiceCallException.java | 44 +++ .../com/iluwatar/timeout/TimeoutExecutor.java | 113 +++++++ .../com/iluwatar/timeout/TimeoutMetrics.java | 71 +++++ .../com/iluwatar/timeout/TimeoutPolicy.java | 63 ++++ .../com/iluwatar/timeout/TimeoutRegistry.java | 72 +++++ .../java/com/iluwatar/timeout/AppTest.java | 91 ++++++ .../timeout/RecommendationServiceTest.java | 54 ++++ .../iluwatar/timeout/TimeoutExecutorTest.java | 159 ++++++++++ .../iluwatar/timeout/TimeoutPolicyTest.java | 53 ++++ .../iluwatar/timeout/TimeoutRegistryTest.java | 54 ++++ 17 files changed, 1446 insertions(+) create mode 100644 timeout/README.md create mode 100644 timeout/etc/timeout.urm.puml create mode 100644 timeout/pom.xml create mode 100644 timeout/src/main/java/com/iluwatar/timeout/App.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/AppTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java diff --git a/pom.xml b/pom.xml index a71630d289d3..49fa5aec66ba 100644 --- a/pom.xml +++ b/pom.xml @@ -260,6 +260,7 @@ rate-limiting-pattern fallback onion-architecture + timeout diff --git a/timeout/README.md b/timeout/README.md new file mode 100644 index 000000000000..fb0496e767ef --- /dev/null +++ b/timeout/README.md @@ -0,0 +1,287 @@ +--- +title: "Timeout Pattern in Java: Bounding the Wait for Slow Dependencies" +shortTitle: Timeout +description: "Learn the Timeout pattern in Java: give every downstream call a per-service time limit, cancel calls that overrun, log and count the events, and continue with a fallback so slow dependencies cannot stall the whole system." +category: Resilience +language: en +tag: + - Asynchronous + - Cloud distributed + - Fault tolerance + - Microservices + - Resilience +--- + +## Also known as + +* Time Limiter +* Deadline + +## Intent of Timeout Design Pattern + +Bound how long a caller waits for a downstream service. When the limit is exceeded the call is abandoned, the event is recorded, and the caller continues with a fallback, so the latency of one slow dependency never becomes the latency of the whole system. + +## Detailed Explanation of Timeout Pattern with Real-World Examples + +Real-world example + +> A pizza chain's online shop asks a separate recommendation engine which side dishes to suggest during checkout. One evening the recommendation engine starts taking twenty seconds per request. Without a limit, every checkout waits those twenty seconds, threads pile up, and soon nobody can order a pizza at all. With a 100 ms limit, the shop stops waiting, shows the always available "most popular sides" list instead, and the order goes through. The slow engine is logged and counted so the on-call engineer can look at it in the morning. + +In plain words + +> Decide up front how long you are willing to wait for a dependency, and when the time is up, stop waiting and move on with a plan B. + +microservices.io says + +> Prevent a client from waiting indefinitely for a response from a service by aborting the request after a specified time period. + +Sequence diagram + +```mermaid +sequenceDiagram + participant Caller + participant TimeoutExecutor + participant Worker as Worker thread + participant Service as Downstream service + + Caller->>TimeoutExecutor: execute(policy, call, fallback) + TimeoutExecutor->>Worker: submit(call) + Worker->>Service: invoke + TimeoutExecutor->>TimeoutExecutor: wait at most policy.timeout() + alt response arrives in time + Service-->>Worker: result + Worker-->>TimeoutExecutor: result + TimeoutExecutor-->>Caller: result + else limit exceeded + TimeoutExecutor->>Worker: cancel(interrupt) + TimeoutExecutor->>TimeoutExecutor: log warning, count timeout + TimeoutExecutor-->>Caller: fallback.get() + end +``` + +## Programmatic Example of Timeout Pattern in Java + +The example models an online shop that calls two downstream services. The product catalog is fast; the recommendation engine is slow. Each gets its own time limit. + +1. **Declare a limit per service** + + A `TimeoutPolicy` couples a service name with the maximum time the caller is willing to wait. The record validates that the limit is positive. + +```java +public record TimeoutPolicy(String serviceName, Duration timeout) { + + public TimeoutPolicy { + Objects.requireNonNull(serviceName, "serviceName"); + Objects.requireNonNull(timeout, "timeout"); + if (serviceName.isBlank()) { + throw new IllegalArgumentException("serviceName must not be blank"); + } + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("timeout must be positive"); + } + } + + public static TimeoutPolicy of(String serviceName, long millis) { + return new TimeoutPolicy(serviceName, Duration.ofMillis(millis)); + } +} +``` + +2. **Keep the limits configurable in one place** + + A `TimeoutRegistry` stores the policies and hands out a default for services nobody configured explicitly. + +```java +public class TimeoutRegistry { + + private final Map policies = new ConcurrentHashMap<>(); + private final Duration defaultTimeout; + + public TimeoutRegistry(Duration defaultTimeout) { + this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout"); + } + + public TimeoutRegistry register(TimeoutPolicy policy) { + policies.put(policy.serviceName(), policy); + return this; + } + + public TimeoutPolicy policyFor(String serviceName) { + return policies.getOrDefault(serviceName, new TimeoutPolicy(serviceName, defaultTimeout)); + } +} +``` + +3. **Enforce the limit** + + `TimeoutExecutor` runs the call on a worker thread and waits for at most the configured duration. On a timeout it cancels the worker with an interrupt, logs the event, counts it in `TimeoutMetrics`, and returns the fallback. A failure raised by the service is not a timeout and is rethrown as `ServiceCallException`. + +```java +@Slf4j +public class TimeoutExecutor implements AutoCloseable { + + private final ExecutorService executor; + private final TimeoutMetrics metrics = new TimeoutMetrics(); + + public TimeoutExecutor() { + this(Executors.newVirtualThreadPerTaskExecutor()); + } + + public T execute(TimeoutPolicy policy, Callable call, Supplier fallback) { + var serviceName = policy.serviceName(); + var limitMillis = policy.timeout().toMillis(); + var future = executor.submit(call); + try { + var result = future.get(limitMillis, TimeUnit.MILLISECONDS); + LOGGER.info("{} responded within its {} ms limit", serviceName, limitMillis); + return result; + } catch (TimeoutException e) { + future.cancel(true); + metrics.recordTimeout(serviceName); + LOGGER.warn( + "{} exceeded its {} ms limit; call cancelled, using fallback", serviceName, limitMillis); + return fallback.get(); + } catch (ExecutionException e) { + throw new ServiceCallException(serviceName, e.getCause()); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new ServiceCallException(serviceName, e); + } + } + + @Override + public void close() { + executor.shutdownNow(); + } +} +``` + +4. **Make the slow service cooperate with cancellation** + + The simulated `RecommendationService` sleeps interruptibly, so the interrupt sent by the executor actually stops the work instead of leaving it running in the background. + +```java +public List recommendationsFor(String customer) throws InterruptedException { + LOGGER.info( + "{}: computing recommendations for {}, expected latency {} ms", + NAME, + customer, + latency.toMillis()); + try { + Thread.sleep(latency); + } catch (InterruptedException e) { + LOGGER.info("{}: interrupted, abandoning the computation for {}", NAME, customer); + throw e; + } + return List.of("Mechanical keyboard", "USB-C dock"); +} +``` + +5. **Wire it together** + + `App` registers a 500 ms limit for the catalog and a 100 ms limit for recommendations. Each call lives in a small helper that pairs the policy with the call and its fallback; `main` runs both. The catalog answers in time; the recommendation engine needs 400 ms, so the customer sees popular items instead and the timeout counter shows one event. + +```java +static List loadProducts( + TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) { + return executor.execute( + registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of); +} + +static List loadRecommendations( + TimeoutExecutor executor, + TimeoutRegistry registry, + RecommendationService recommendations, + String customer) { + return executor.execute( + registry.policyFor(RecommendationService.NAME), + () -> recommendations.recommendationsFor(customer), + () -> POPULAR_ITEMS); +} +``` + +```java +var registry = + new TimeoutRegistry(Duration.ofMillis(300)) + .register(TimeoutPolicy.of(ProductCatalogService.NAME, 500)) + .register(TimeoutPolicy.of(RecommendationService.NAME, 100)); + +var catalog = new ProductCatalogService(Duration.ofMillis(50)); +var recommendations = new RecommendationService(Duration.ofMillis(400)); + +try (var executor = new TimeoutExecutor()) { + var products = loadProducts(executor, registry, catalog); + LOGGER.info("Products: {}", products); + + var suggested = loadRecommendations(executor, registry, recommendations, "alice"); + LOGGER.info("Recommendations shown to alice: {}", suggested); + + LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot()); +} +``` + +Running the application produces output along these lines: + +``` +Configured per-service limits: catalog 500 ms, recommendations 100 ms +Calling product-catalog +product-catalog: fetching products, expected latency 50 ms +product-catalog responded within its 500 ms limit +Products: [Laptop, Headphones, Monitor] +Calling recommendations +recommendations: computing recommendations for alice, expected latency 400 ms +recommendations exceeded its 100 ms limit; call cancelled, using fallback +recommendations: interrupted, abandoning the computation for alice +Recommendations shown to alice: [Wireless mouse, Webcam] +Timeouts per service: {recommendations=1} +``` + +## Class diagram + +See [timeout.urm.puml](./etc/timeout.urm.puml) for the PlantUML class diagram. + +## When to Use the Timeout Pattern in Java + +* Whenever a call leaves the process: HTTP and gRPC calls, database queries, message broker round trips, third-party APIs. +* When a degraded answer delivered on time is worth more than a perfect answer delivered late. +* When threads, connections or other pooled resources are held for the duration of a call and must not be tied up by a stalled dependency. +* When different dependencies have different latency profiles and need individually tuned limits. + +## Real-World Applications of Timeout Pattern in Java + +* [Resilience4j TimeLimiter](https://resilience4j.readme.io/docs/timelimiter) wraps a `CompletableFuture` or `Future` with a configurable limit and optional cancellation. +* [Netflix Hystrix](https://github.com/Netflix/Hystrix/wiki/Configuration#execution.isolation.thread.timeoutInMilliseconds) applied a per-command execution timeout before falling back. +* [gRPC deadlines](https://grpc.io/docs/guides/deadlines/) propagate a limit across service hops. +* `java.net.http.HttpClient` connect and request timeouts, JDBC `queryTimeout`, and `Future.get(long, TimeUnit)` in the JDK. + +## Benefits and Trade-offs of Timeout Pattern + +Benefits: + +* **Predictable latency**: The caller's worst case is the configured limit plus the fallback cost, not the dependency's worst case. +* **Failure containment**: Stalled dependencies stop consuming threads and connections, which prevents cascading failures. +* **Observability**: Every timeout is logged and counted, exposing dependencies that regularly miss their budget. +* **Independent tuning**: Each service gets a limit that matches its normal latency. + +Trade-offs: + +* **Choosing the value is hard**: Too short causes false alarms under normal jitter; too long defeats the purpose. +* **Wasted work**: A cancelled call may already have done its side effects, so operations that are not idempotent need care. +* **Cooperative cancellation**: An interrupt only stops code that checks for it; blocking calls that ignore interrupts keep running until they finish on their own. +* **Fallback quality**: The fallback must be genuinely cheap and safe, otherwise the pattern only moves the problem. + +## Related Java Design Patterns + +* [Fallback](../fallback): Supplies the alternative answer once a timeout fires. The fallback module treats the time limit as one of several triggers; this module makes the limit itself the subject, with per-service configuration, cancellation and metrics. +* [Circuit Breaker](../circuit-breaker): Counts timeouts as failures and stops calling a dependency that keeps overrunning its limit. +* [Retry](../retry): Retries a call that timed out, ideally with a total deadline so retries cannot multiply the wait. +* Bulkhead: Limits how many concurrent calls a dependency may hold, complementing the limit on how long each call may take. + +## References and Credits + +* [Timeout pattern (microservices.io)](https://microservices.io/patterns/reliability/timeout.html) +* [Release It! Design and Deploy Production-Ready Software](https://amzn.to/4aqTNEP) +* [Microservices Patterns: With examples in Java](https://amzn.to/3xaZwk0) +* [Resilience4j TimeLimiter documentation](https://resilience4j.readme.io/docs/timelimiter) +* [gRPC deadlines](https://grpc.io/docs/guides/deadlines/) diff --git a/timeout/etc/timeout.urm.puml b/timeout/etc/timeout.urm.puml new file mode 100644 index 000000000000..a13d37c318b0 --- /dev/null +++ b/timeout/etc/timeout.urm.puml @@ -0,0 +1,65 @@ +@startuml +package com.iluwatar.timeout { + class TimeoutPolicy { + - serviceName : String + - timeout : Duration + + TimeoutPolicy(serviceName : String, timeout : Duration) + + of(serviceName : String, millis : long) : TimeoutPolicy {static} + + serviceName() : String + + timeout() : Duration + } + class TimeoutRegistry { + - policies : Map + - defaultTimeout : Duration + + TimeoutRegistry(defaultTimeout : Duration) + + register(policy : TimeoutPolicy) : TimeoutRegistry + + policyFor(serviceName : String) : TimeoutPolicy + } + class TimeoutMetrics { + - timeouts : ConcurrentMap + + TimeoutMetrics() + + recordTimeout(serviceName : String) : void + + timeoutCount(serviceName : String) : int + + snapshot() : Map + } + class TimeoutExecutor { + - executor : ExecutorService + - metrics : TimeoutMetrics + + TimeoutExecutor() + + TimeoutExecutor(executor : ExecutorService) + + execute(policy : TimeoutPolicy, call : Callable, fallback : Supplier) : T + + metrics() : TimeoutMetrics + + close() : void + } + class ServiceCallException { + + ServiceCallException(serviceName : String, cause : Throwable) + } + class ProductCatalogService { + + NAME : String {static} + - latency : Duration + + ProductCatalogService(latency : Duration) + + fetchProducts() : List + } + class RecommendationService { + + NAME : String {static} + - latency : Duration + + RecommendationService(latency : Duration) + + recommendationsFor(customer : String) : List + } + class App { + - POPULAR_ITEMS : List {static} + + App() + + main(args : String[]) : void + ~ loadProducts(executor : TimeoutExecutor, registry : TimeoutRegistry, catalog : ProductCatalogService) : List {static} + ~ loadRecommendations(executor : TimeoutExecutor, registry : TimeoutRegistry, recommendations : RecommendationService, customer : String) : List {static} + } +} +TimeoutExecutor --> TimeoutMetrics +TimeoutExecutor ..> TimeoutPolicy +TimeoutExecutor ..> ServiceCallException +TimeoutRegistry --> "*" TimeoutPolicy +App ..> TimeoutRegistry +App ..> TimeoutExecutor +App ..> ProductCatalogService +App ..> RecommendationService +@enduml diff --git a/timeout/pom.xml b/timeout/pom.xml new file mode 100644 index 000000000000..b35428f53a8d --- /dev/null +++ b/timeout/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + timeout + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + + com.iluwatar.timeout.App + + + + + + + + + diff --git a/timeout/src/main/java/com/iluwatar/timeout/App.java b/timeout/src/main/java/com/iluwatar/timeout/App.java new file mode 100644 index 000000000000..7a0001809475 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/App.java @@ -0,0 +1,113 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * The Timeout pattern bounds how long a caller waits for a downstream service. Without a limit a + * single slow dependency can hold threads, connections and user requests hostage until the whole + * system stalls. With a limit the caller abandons the slow call, records the event and continues + * with a fallback, keeping latency predictable and failures contained. + * + *

The building blocks are a {@link TimeoutPolicy} per service, a {@link TimeoutRegistry} that + * makes the limits configurable in one place, and a {@link TimeoutExecutor} that enforces them, + * cancels calls that overrun, and counts timeouts in {@link TimeoutMetrics}. + * + *

The demo wires two services with different limits. The product catalog answers well within its + * 500 ms budget and returns real data. The recommendation engine needs 400 ms but is only allowed + * 100 ms, so its call is cancelled and the customer sees popular items instead. The timeout + * counters are printed at the end. + */ +@Slf4j +public class App { + + private static final List POPULAR_ITEMS = List.of("Wireless mouse", "Webcam"); + + /** + * Program entry point. + * + * @param args command line arguments, not used + */ + public static void main(String[] args) { + var registry = + new TimeoutRegistry(Duration.ofMillis(300)) + .register(TimeoutPolicy.of(ProductCatalogService.NAME, 500)) + .register(TimeoutPolicy.of(RecommendationService.NAME, 100)); + LOGGER.info("Configured per-service limits: catalog 500 ms, recommendations 100 ms"); + + var catalog = new ProductCatalogService(Duration.ofMillis(50)); + var recommendations = new RecommendationService(Duration.ofMillis(400)); + + try (var executor = new TimeoutExecutor()) { + LOGGER.info("Calling {}", ProductCatalogService.NAME); + var products = loadProducts(executor, registry, catalog); + LOGGER.info("Products: {}", products); + + LOGGER.info("Calling {}", RecommendationService.NAME); + var suggested = loadRecommendations(executor, registry, recommendations, "alice"); + LOGGER.info("Recommendations shown to alice: {}", suggested); + + LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot()); + } + } + + /** + * Loads the catalog under its time limit, showing an empty catalog if the limit is exceeded. + * + * @param executor executor enforcing the limit + * @param registry registry holding the catalog's policy + * @param catalog the downstream catalog service + * @return the products, or an empty list on timeout + */ + static List loadProducts( + TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) { + return executor.execute( + registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of); + } + + /** + * Loads personalised recommendations under their time limit, showing popular items instead if the + * limit is exceeded. + * + * @param executor executor enforcing the limit + * @param registry registry holding the recommendation service's policy + * @param recommendations the downstream recommendation service + * @param customer customer to personalise for + * @return the recommendations, or the popular items on timeout + */ + static List loadRecommendations( + TimeoutExecutor executor, + TimeoutRegistry registry, + RecommendationService recommendations, + String customer) { + return executor.execute( + registry.policyFor(RecommendationService.NAME), + () -> recommendations.recommendationsFor(customer), + () -> POPULAR_ITEMS); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java b/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java new file mode 100644 index 000000000000..d16d6d7019a5 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java @@ -0,0 +1,63 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Simulated product catalog service. It answers quickly, so its calls normally complete well within + * their limit. + */ +@Slf4j +public class ProductCatalogService { + + /** Name under which the service is registered in the {@link TimeoutRegistry}. */ + public static final String NAME = "product-catalog"; + + private final Duration latency; + + /** + * Creates the service. + * + * @param latency simulated response time + */ + public ProductCatalogService(Duration latency) { + this.latency = latency; + } + + /** + * Lists the products in the catalog. + * + * @return product names + * @throws InterruptedException if the call is cancelled while waiting for the simulated backend + */ + public List fetchProducts() throws InterruptedException { + LOGGER.info("{}: fetching products, expected latency {} ms", NAME, latency.toMillis()); + Thread.sleep(latency); + return List.of("Laptop", "Headphones", "Monitor"); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java b/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java new file mode 100644 index 000000000000..cdaf8fb64c04 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java @@ -0,0 +1,73 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Simulated recommendation engine. It is slow, so it demonstrates what happens when a dependency + * misses its limit: the call is interrupted and the caller continues with a fallback. + */ +@Slf4j +public class RecommendationService { + + /** Name under which the service is registered in the {@link TimeoutRegistry}. */ + public static final String NAME = "recommendations"; + + private final Duration latency; + + /** + * Creates the service. + * + * @param latency simulated response time + */ + public RecommendationService(Duration latency) { + this.latency = latency; + } + + /** + * Computes personalised recommendations for a customer. + * + * @param customer customer identifier + * @return recommended product names + * @throws InterruptedException if the call is cancelled before the computation finishes + */ + public List recommendationsFor(String customer) throws InterruptedException { + LOGGER.info( + "{}: computing recommendations for {}, expected latency {} ms", + NAME, + customer, + latency.toMillis()); + try { + Thread.sleep(latency); + } catch (InterruptedException e) { + LOGGER.info("{}: interrupted, abandoning the computation for {}", NAME, customer); + throw e; + } + return List.of("Mechanical keyboard", "USB-C dock"); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.java b/timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.java new file mode 100644 index 000000000000..c21a387dc276 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.java @@ -0,0 +1,44 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +/** + * Signals that a downstream call failed for a reason other than exceeding its time limit. + * + *

Such failures are deliberately not masked by the fallback: a timeout means "too slow", while + * an exception from the service means "broken", and the two deserve different handling. + */ +public class ServiceCallException extends RuntimeException { + + /** + * Creates the exception. + * + * @param serviceName name of the service whose call failed + * @param cause the failure raised by the service + */ + public ServiceCallException(String serviceName, Throwable cause) { + super("Call to " + serviceName + " failed", cause); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java new file mode 100644 index 000000000000..33f248b1aff9 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java @@ -0,0 +1,113 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; + +/** + * Runs downstream calls under the time limit declared by their {@link TimeoutPolicy}. + * + *

The call is executed on a separate thread while the caller waits for at most the configured + * duration. When the limit is exceeded the call is cancelled with an interrupt, the event is logged + * and counted in {@link TimeoutMetrics}, and the supplied fallback provides the answer instead. + * Failures raised by the service itself are not treated as timeouts; they surface as {@link + * ServiceCallException}. + */ +@Slf4j +public class TimeoutExecutor implements AutoCloseable { + + private final ExecutorService executor; + private final TimeoutMetrics metrics = new TimeoutMetrics(); + + /** Creates an executor that runs every call on its own virtual thread. */ + public TimeoutExecutor() { + this(Executors.newVirtualThreadPerTaskExecutor()); + } + + /** + * Creates an executor backed by the given thread pool. + * + * @param executor pool used to run the calls + */ + public TimeoutExecutor(ExecutorService executor) { + this.executor = Objects.requireNonNull(executor, "executor"); + } + + /** + * Executes a call within the limit of its policy. + * + * @param policy limit that applies to the call + * @param call the downstream invocation + * @param fallback answer to use when the call does not complete in time + * @param type of the response + * @return the response of the call, or the fallback if the limit was exceeded + * @throws ServiceCallException if the call fails or the waiting thread is interrupted + */ + public T execute(TimeoutPolicy policy, Callable call, Supplier fallback) { + var serviceName = policy.serviceName(); + var limitMillis = policy.timeout().toMillis(); + var future = executor.submit(call); + try { + var result = future.get(limitMillis, TimeUnit.MILLISECONDS); + LOGGER.info("{} responded within its {} ms limit", serviceName, limitMillis); + return result; + } catch (TimeoutException e) { + future.cancel(true); + metrics.recordTimeout(serviceName); + LOGGER.warn( + "{} exceeded its {} ms limit; call cancelled, using fallback", serviceName, limitMillis); + return fallback.get(); + } catch (ExecutionException e) { + throw new ServiceCallException(serviceName, e.getCause()); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new ServiceCallException(serviceName, e); + } + } + + /** + * Exposes the timeout counters. + * + * @return the metrics collected so far + */ + public TimeoutMetrics metrics() { + return metrics; + } + + /** Stops the underlying thread pool, interrupting any call that is still running. */ + @Override + public void close() { + executor.shutdownNow(); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java new file mode 100644 index 000000000000..bcc820ef3250 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java @@ -0,0 +1,71 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Counts timeout events per service so that operators can spot dependencies that regularly miss + * their limits. + */ +public class TimeoutMetrics { + + private final ConcurrentMap timeouts = new ConcurrentHashMap<>(); + + /** + * Records one timeout for a service. + * + * @param serviceName name of the service that missed its limit + */ + public void recordTimeout(String serviceName) { + timeouts.computeIfAbsent(serviceName, name -> new AtomicInteger()).incrementAndGet(); + } + + /** + * Returns how many times a service has timed out. + * + * @param serviceName name of the service + * @return the timeout count, zero if the service never timed out + */ + public int timeoutCount(String serviceName) { + var counter = timeouts.get(serviceName); + return counter == null ? 0 : counter.get(); + } + + /** + * Returns a sorted, read-only view of all counters. + * + * @return service name to timeout count + */ + public Map snapshot() { + var snapshot = new TreeMap(); + timeouts.forEach((name, counter) -> snapshot.put(name, counter.get())); + return Map.copyOf(snapshot); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java new file mode 100644 index 000000000000..00e1145e3fec --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java @@ -0,0 +1,63 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import java.time.Duration; +import java.util.Objects; + +/** + * Declares how long a call to a named downstream service may take before it is abandoned. + * + *

Each service gets its own policy so that a fast catalog lookup and a slow recommendation + * engine can be governed by different limits. + * + * @param serviceName name of the downstream service the policy applies to + * @param timeout maximum time the caller is willing to wait for a response + */ +public record TimeoutPolicy(String serviceName, Duration timeout) { + + /** Validates that the policy names a service and carries a positive limit. */ + public TimeoutPolicy { + Objects.requireNonNull(serviceName, "serviceName"); + Objects.requireNonNull(timeout, "timeout"); + if (serviceName.isBlank()) { + throw new IllegalArgumentException("serviceName must not be blank"); + } + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("timeout must be positive"); + } + } + + /** + * Convenience factory for millisecond based limits. + * + * @param serviceName name of the downstream service + * @param millis limit in milliseconds + * @return the policy + */ + public static TimeoutPolicy of(String serviceName, long millis) { + return new TimeoutPolicy(serviceName, Duration.ofMillis(millis)); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java new file mode 100644 index 000000000000..67b712406983 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java @@ -0,0 +1,72 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Holds the {@link TimeoutPolicy} configured for each downstream service. + * + *

Services that have no explicit policy fall back to a default limit, so callers never have to + * hard code a duration next to the call site. + */ +public class TimeoutRegistry { + + private final Map policies = new ConcurrentHashMap<>(); + private final Duration defaultTimeout; + + /** + * Creates a registry. + * + * @param defaultTimeout limit applied to services without an explicit policy + */ + public TimeoutRegistry(Duration defaultTimeout) { + this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout"); + } + + /** + * Registers or replaces the policy of a service. + * + * @param policy the policy to store + * @return this registry for chaining + */ + public TimeoutRegistry register(TimeoutPolicy policy) { + policies.put(policy.serviceName(), policy); + return this; + } + + /** + * Looks up the policy of a service. + * + * @param serviceName name of the downstream service + * @return the registered policy, or one built from the default limit + */ + public TimeoutPolicy policyFor(String serviceName) { + return policies.getOrDefault(serviceName, new TimeoutPolicy(serviceName, defaultTimeout)); + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/AppTest.java b/timeout/src/test/java/com/iluwatar/timeout/AppTest.java new file mode 100644 index 000000000000..8c77b5cf5bff --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/AppTest.java @@ -0,0 +1,91 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AppTest { + + private static final TimeoutRegistry GENEROUS = new TimeoutRegistry(Duration.ofSeconds(5)); + private static final TimeoutRegistry STRICT = new TimeoutRegistry(Duration.ofMillis(50)); + + @Test + void shouldLaunchApp() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } + + @Test + void shouldBeInstantiable() { + assertNotNull(new App(), "App should be instantiable"); + } + + @Test + void loadsProductsWithinLimit() { + try (var executor = new TimeoutExecutor()) { + var products = + App.loadProducts(executor, GENEROUS, new ProductCatalogService(Duration.ofMillis(1))); + + assertEquals(List.of("Laptop", "Headphones", "Monitor"), products); + } + } + + @Test + void showsEmptyCatalogWhenCatalogExceedsLimit() { + try (var executor = new TimeoutExecutor()) { + var products = + App.loadProducts(executor, STRICT, new ProductCatalogService(Duration.ofSeconds(60))); + + assertEquals(List.of(), products); + } + } + + @Test + void loadsRecommendationsWithinLimit() { + try (var executor = new TimeoutExecutor()) { + var suggested = + App.loadRecommendations( + executor, GENEROUS, new RecommendationService(Duration.ofMillis(1)), "alice"); + + assertEquals(List.of("Mechanical keyboard", "USB-C dock"), suggested); + } + } + + @Test + void showsPopularItemsWhenRecommendationsExceedLimit() { + try (var executor = new TimeoutExecutor()) { + var suggested = + App.loadRecommendations( + executor, STRICT, new RecommendationService(Duration.ofSeconds(60)), "alice"); + + assertEquals(List.of("Wireless mouse", "Webcam"), suggested); + } + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java b/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java new file mode 100644 index 000000000000..0b838956a8ff --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java @@ -0,0 +1,54 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RecommendationServiceTest { + + @Test + void returnsRecommendationsAfterSimulatedLatency() throws InterruptedException { + var service = new RecommendationService(Duration.ofMillis(1)); + + assertEquals(List.of("Mechanical keyboard", "USB-C dock"), service.recommendationsFor("alice")); + } + + @Test + void abandonsComputationWhenInterrupted() { + var service = new RecommendationService(Duration.ofSeconds(60)); + + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, () -> service.recommendationsFor("alice")); + } finally { + Thread.interrupted(); + } + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java new file mode 100644 index 000000000000..98b92b8c3b4a --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java @@ -0,0 +1,159 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class TimeoutExecutorTest { + + private static final TimeoutPolicy GENEROUS = TimeoutPolicy.of("generous", 5_000); + private static final TimeoutPolicy STRICT = TimeoutPolicy.of("strict", 50); + + private final TimeoutExecutor executor = new TimeoutExecutor(); + + @AfterEach + void tearDown() { + executor.close(); + } + + @Test + void returnsResultWhenCallCompletesWithinLimit() { + var result = executor.execute(GENEROUS, () -> "fresh", () -> "fallback"); + + assertEquals("fresh", result); + assertEquals(0, executor.metrics().timeoutCount(GENEROUS.serviceName())); + } + + @Test + void returnsFallbackAndCancelsCallWhenLimitExceeded() throws InterruptedException { + var interrupted = new CountDownLatch(1); + Callable hangingCall = + () -> { + try { + Thread.sleep(60_000); + } catch (InterruptedException e) { + interrupted.countDown(); + throw e; + } + return "too late"; + }; + + var result = executor.execute(STRICT, hangingCall, () -> "fallback"); + + assertEquals("fallback", result); + assertTrue(interrupted.await(5, TimeUnit.SECONDS), "slow call should have been interrupted"); + assertEquals(1, executor.metrics().timeoutCount(STRICT.serviceName())); + } + + @Test + void propagatesServiceFailureInsteadOfFallingBack() { + var failure = new IllegalStateException("backend down"); + + var thrown = + assertThrows( + ServiceCallException.class, + () -> + executor.execute( + GENEROUS, + () -> { + throw failure; + }, + () -> "fallback")); + + assertSame(failure, thrown.getCause()); + assertEquals(0, executor.metrics().timeoutCount(GENEROUS.serviceName())); + } + + @Test + void appliesDifferentLimitsPerService() { + Callable slowCall = + () -> { + Thread.sleep(200); + return "slow but done"; + }; + + var withinBudget = executor.execute(GENEROUS, slowCall, () -> "fallback"); + var overBudget = executor.execute(STRICT, slowCall, () -> "fallback"); + + assertEquals("slow but done", withinBudget); + assertEquals("fallback", overBudget); + } + + @Test + void countsTimeoutsPerService() { + var other = TimeoutPolicy.of("other", 50); + Callable slowCall = + () -> { + Thread.sleep(60_000); + return "never"; + }; + + executor.execute(STRICT, slowCall, () -> "fallback"); + executor.execute(STRICT, slowCall, () -> "fallback"); + executor.execute(other, slowCall, () -> "fallback"); + + assertEquals(Map.of("strict", 2, "other", 1), executor.metrics().snapshot()); + } + + @Test + void propagatesInterruptionOfTheCaller() { + Callable slowCall = + () -> { + Thread.sleep(60_000); + return "never"; + }; + + Thread.currentThread().interrupt(); + var thrown = + assertThrows( + ServiceCallException.class, + () -> executor.execute(GENEROUS, slowCall, () -> "fallback")); + + assertTrue(Thread.interrupted(), "interrupt flag should be preserved for the caller"); + assertInstanceOf(InterruptedException.class, thrown.getCause()); + assertEquals(0, executor.metrics().timeoutCount(GENEROUS.serviceName())); + } + + @Test + void rejectsCallsAfterClose() { + executor.close(); + + assertThrows( + RejectedExecutionException.class, + () -> executor.execute(GENEROUS, () -> "ignored", () -> "fallback")); + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.java new file mode 100644 index 000000000000..b65933eedd1c --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.java @@ -0,0 +1,53 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class TimeoutPolicyTest { + + @Test + void buildsPolicyFromMilliseconds() { + var policy = TimeoutPolicy.of("catalog", 250); + + assertEquals("catalog", policy.serviceName()); + assertEquals(Duration.ofMillis(250), policy.timeout()); + } + + @Test + void rejectsBlankServiceName() { + assertThrows(IllegalArgumentException.class, () -> TimeoutPolicy.of(" ", 250)); + } + + @Test + void rejectsNonPositiveTimeout() { + assertThrows(IllegalArgumentException.class, () -> TimeoutPolicy.of("catalog", 0)); + assertThrows(IllegalArgumentException.class, () -> TimeoutPolicy.of("catalog", -1)); + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java new file mode 100644 index 000000000000..069b0e39e93c --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java @@ -0,0 +1,54 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class TimeoutRegistryTest { + + private final TimeoutRegistry registry = new TimeoutRegistry(Duration.ofMillis(300)); + + @Test + void returnsRegisteredPolicy() { + registry.register(TimeoutPolicy.of("catalog", 500)); + + assertEquals(TimeoutPolicy.of("catalog", 500), registry.policyFor("catalog")); + } + + @Test + void fallsBackToDefaultLimitForUnknownService() { + assertEquals(TimeoutPolicy.of("unknown", 300), registry.policyFor("unknown")); + } + + @Test + void replacesExistingPolicy() { + registry.register(TimeoutPolicy.of("catalog", 500)).register(TimeoutPolicy.of("catalog", 50)); + + assertEquals(Duration.ofMillis(50), registry.policyFor("catalog").timeout()); + } +} From 2458cf8a8f36db4450c661c4f6eb87465e420138 Mon Sep 17 00:00:00 2001 From: Doksanbir Date: Mon, 7 Sep 2026 11:04:24 +0300 Subject: [PATCH 2/2] refactor: simplify the timeout demo and address review findings The metrics snapshot stays sorted, a rejected submission surfaces as ServiceCallException, TimeoutRegistry is dropped and the two demo services merge into DownstreamService. The class diagram is a rendered PNG. --- timeout/README.md | 104 ++++++------------ timeout/etc/timeout.urm.png | Bin 0 -> 45889 bytes timeout/etc/timeout.urm.puml | 39 +++---- .../main/java/com/iluwatar/timeout/App.java | 68 +++++------- ...ionService.java => DownstreamService.java} | 48 ++++---- .../timeout/ProductCatalogService.java | 63 ----------- .../com/iluwatar/timeout/TimeoutExecutor.java | 10 +- .../com/iluwatar/timeout/TimeoutMetrics.java | 7 +- .../com/iluwatar/timeout/TimeoutRegistry.java | 72 ------------ .../java/com/iluwatar/timeout/AppTest.java | 41 ++----- ...ceTest.java => DownstreamServiceTest.java} | 15 +-- .../iluwatar/timeout/TimeoutExecutorTest.java | 22 +++- .../iluwatar/timeout/TimeoutRegistryTest.java | 54 --------- 13 files changed, 151 insertions(+), 392 deletions(-) create mode 100644 timeout/etc/timeout.urm.png rename timeout/src/main/java/com/iluwatar/timeout/{RecommendationService.java => DownstreamService.java} (59%) delete mode 100644 timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java delete mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java rename timeout/src/test/java/com/iluwatar/timeout/{RecommendationServiceTest.java => DownstreamServiceTest.java} (76%) delete mode 100644 timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java diff --git a/timeout/README.md b/timeout/README.md index fb0496e767ef..44eeeb16a23e 100644 --- a/timeout/README.md +++ b/timeout/README.md @@ -59,6 +59,8 @@ sequenceDiagram end ``` +![Timeout class diagram](./etc/timeout.urm.png) + ## Programmatic Example of Timeout Pattern in Java The example models an online shop that calls two downstream services. The product catalog is fast; the recommendation engine is slow. Each gets its own time limit. @@ -87,34 +89,9 @@ public record TimeoutPolicy(String serviceName, Duration timeout) { } ``` -2. **Keep the limits configurable in one place** +2. **Enforce the limit** - A `TimeoutRegistry` stores the policies and hands out a default for services nobody configured explicitly. - -```java -public class TimeoutRegistry { - - private final Map policies = new ConcurrentHashMap<>(); - private final Duration defaultTimeout; - - public TimeoutRegistry(Duration defaultTimeout) { - this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout"); - } - - public TimeoutRegistry register(TimeoutPolicy policy) { - policies.put(policy.serviceName(), policy); - return this; - } - - public TimeoutPolicy policyFor(String serviceName) { - return policies.getOrDefault(serviceName, new TimeoutPolicy(serviceName, defaultTimeout)); - } -} -``` - -3. **Enforce the limit** - - `TimeoutExecutor` runs the call on a worker thread and waits for at most the configured duration. On a timeout it cancels the worker with an interrupt, logs the event, counts it in `TimeoutMetrics`, and returns the fallback. A failure raised by the service is not a timeout and is rethrown as `ServiceCallException`. + `TimeoutExecutor` runs the call on a worker thread and waits for at most the configured duration. On a timeout it cancels the worker with an interrupt, logs the event, counts it in `TimeoutMetrics`, and returns the fallback. A failure raised by the service, or a call the pool refuses to accept, is not a timeout and is rethrown as `ServiceCallException`. ```java @Slf4j @@ -130,8 +107,9 @@ public class TimeoutExecutor implements AutoCloseable { public T execute(TimeoutPolicy policy, Callable call, Supplier fallback) { var serviceName = policy.serviceName(); var limitMillis = policy.timeout().toMillis(); - var future = executor.submit(call); + Future future = null; try { + future = executor.submit(call); var result = future.get(limitMillis, TimeUnit.MILLISECONDS); LOGGER.info("{} responded within its {} ms limit", serviceName, limitMillis); return result; @@ -147,6 +125,8 @@ public class TimeoutExecutor implements AutoCloseable { future.cancel(true); Thread.currentThread().interrupt(); throw new ServiceCallException(serviceName, e); + } catch (RejectedExecutionException e) { + throw new ServiceCallException(serviceName, e); } } @@ -157,64 +137,52 @@ public class TimeoutExecutor implements AutoCloseable { } ``` -4. **Make the slow service cooperate with cancellation** +3. **Make the service cooperate with cancellation** - The simulated `RecommendationService` sleeps interruptibly, so the interrupt sent by the executor actually stops the work instead of leaving it running in the background. + The simulated `DownstreamService` sleeps interruptibly, so the interrupt sent by the executor actually stops the work instead of leaving it running in the background. Its name, latency and payload are constructor arguments, so the same class plays both the fast catalog and the slow recommendation engine. ```java -public List recommendationsFor(String customer) throws InterruptedException { - LOGGER.info( - "{}: computing recommendations for {}, expected latency {} ms", - NAME, - customer, - latency.toMillis()); +public List fetch() throws InterruptedException { + LOGGER.info("{}: responding, expected latency {} ms", name, latency.toMillis()); try { Thread.sleep(latency); } catch (InterruptedException e) { - LOGGER.info("{}: interrupted, abandoning the computation for {}", NAME, customer); + LOGGER.info("{}: interrupted, abandoning the call", name); throw e; } - return List.of("Mechanical keyboard", "USB-C dock"); + return items; } ``` -5. **Wire it together** +4. **Wire it together** - `App` registers a 500 ms limit for the catalog and a 100 ms limit for recommendations. Each call lives in a small helper that pairs the policy with the call and its fallback; `main` runs both. The catalog answers in time; the recommendation engine needs 400 ms, so the customer sees popular items instead and the timeout counter shows one event. + `App` gives the catalog a 500 ms limit and recommendations a 100 ms one, then runs both calls through a small helper that pairs the policy with the call and its fallback. The catalog answers in time; the recommendation engine needs 400 ms, so the customer sees popular items instead and the timeout counter shows one event. ```java -static List loadProducts( - TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) { - return executor.execute( - registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of); -} - -static List loadRecommendations( +static List call( TimeoutExecutor executor, - TimeoutRegistry registry, - RecommendationService recommendations, - String customer) { - return executor.execute( - registry.policyFor(RecommendationService.NAME), - () -> recommendations.recommendationsFor(customer), - () -> POPULAR_ITEMS); + TimeoutPolicy policy, + DownstreamService service, + List fallback) { + return executor.execute(policy, service::fetch, () -> fallback); } ``` ```java -var registry = - new TimeoutRegistry(Duration.ofMillis(300)) - .register(TimeoutPolicy.of(ProductCatalogService.NAME, 500)) - .register(TimeoutPolicy.of(RecommendationService.NAME, 100)); - -var catalog = new ProductCatalogService(Duration.ofMillis(50)); -var recommendations = new RecommendationService(Duration.ofMillis(400)); +var catalog = + new DownstreamService( + "product-catalog", Duration.ofMillis(50), List.of("Laptop", "Headphones", "Monitor")); +var recommendations = + new DownstreamService( + "recommendations", Duration.ofMillis(400), List.of("Mechanical keyboard", "USB-C dock")); +var catalogPolicy = TimeoutPolicy.of(catalog.name(), 500); +var recommendationPolicy = TimeoutPolicy.of(recommendations.name(), 100); try (var executor = new TimeoutExecutor()) { - var products = loadProducts(executor, registry, catalog); + var products = call(executor, catalogPolicy, catalog, List.of()); LOGGER.info("Products: {}", products); - var suggested = loadRecommendations(executor, registry, recommendations, "alice"); + var suggested = call(executor, recommendationPolicy, recommendations, POPULAR_ITEMS); LOGGER.info("Recommendations shown to alice: {}", suggested); LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot()); @@ -226,20 +194,18 @@ Running the application produces output along these lines: ``` Configured per-service limits: catalog 500 ms, recommendations 100 ms Calling product-catalog -product-catalog: fetching products, expected latency 50 ms +product-catalog: responding, expected latency 50 ms product-catalog responded within its 500 ms limit Products: [Laptop, Headphones, Monitor] Calling recommendations -recommendations: computing recommendations for alice, expected latency 400 ms +recommendations: responding, expected latency 400 ms +recommendations: interrupted, abandoning the call recommendations exceeded its 100 ms limit; call cancelled, using fallback -recommendations: interrupted, abandoning the computation for alice Recommendations shown to alice: [Wireless mouse, Webcam] Timeouts per service: {recommendations=1} ``` -## Class diagram - -See [timeout.urm.puml](./etc/timeout.urm.puml) for the PlantUML class diagram. +The "exceeded its 100 ms limit" line is written by the caller and the "interrupted, abandoning" line by the worker thread, so the two may appear in either order from run to run. ## When to Use the Timeout Pattern in Java diff --git a/timeout/etc/timeout.urm.png b/timeout/etc/timeout.urm.png new file mode 100644 index 0000000000000000000000000000000000000000..d441cd7b1029a51bb690872c2c34bb827c7fda52 GIT binary patch literal 45889 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 z

7c)u9_!`;0%EqZ+Iy4@v|GwBZa#tOkvAON3O`}5 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 wo2_*Hdnp0JOac082tPaHG5>o#nZXC-q*}kN^Mx literal 0 HcmV?d00001 diff --git a/timeout/etc/timeout.urm.puml b/timeout/etc/timeout.urm.puml index a13d37c318b0..ebe24baabfb9 100644 --- a/timeout/etc/timeout.urm.puml +++ b/timeout/etc/timeout.urm.puml @@ -1,6 +1,6 @@ @startuml package com.iluwatar.timeout { - class TimeoutPolicy { + class TimeoutPolicy <> { - serviceName : String - timeout : Duration + TimeoutPolicy(serviceName : String, timeout : Duration) @@ -8,19 +8,12 @@ package com.iluwatar.timeout { + serviceName() : String + timeout() : Duration } - class TimeoutRegistry { - - policies : Map - - defaultTimeout : Duration - + TimeoutRegistry(defaultTimeout : Duration) - + register(policy : TimeoutPolicy) : TimeoutRegistry - + policyFor(serviceName : String) : TimeoutPolicy - } class TimeoutMetrics { - timeouts : ConcurrentMap + TimeoutMetrics() + recordTimeout(serviceName : String) : void + timeoutCount(serviceName : String) : int - + snapshot() : Map + + snapshot() : SortedMap } class TimeoutExecutor { - executor : ExecutorService @@ -34,32 +27,26 @@ package com.iluwatar.timeout { class ServiceCallException { + ServiceCallException(serviceName : String, cause : Throwable) } - class ProductCatalogService { - + NAME : String {static} - - latency : Duration - + ProductCatalogService(latency : Duration) - + fetchProducts() : List - } - class RecommendationService { - + NAME : String {static} + class DownstreamService { + - name : String - latency : Duration - + RecommendationService(latency : Duration) - + recommendationsFor(customer : String) : List + - items : List + + DownstreamService(name : String, latency : Duration, items : List) + + name() : String + + fetch() : List } class App { - POPULAR_ITEMS : List {static} - + App() + main(args : String[]) : void - ~ loadProducts(executor : TimeoutExecutor, registry : TimeoutRegistry, catalog : ProductCatalogService) : List {static} - ~ loadRecommendations(executor : TimeoutExecutor, registry : TimeoutRegistry, recommendations : RecommendationService, customer : String) : List {static} + ~ call(executor : TimeoutExecutor, policy : TimeoutPolicy, service : DownstreamService, fallback : List) : List {static} } } +ServiceCallException --|> RuntimeException TimeoutExecutor --> TimeoutMetrics TimeoutExecutor ..> TimeoutPolicy TimeoutExecutor ..> ServiceCallException -TimeoutRegistry --> "*" TimeoutPolicy -App ..> TimeoutRegistry +App ..> TimeoutPolicy App ..> TimeoutExecutor -App ..> ProductCatalogService -App ..> RecommendationService +App ..> TimeoutMetrics +App ..> DownstreamService @enduml diff --git a/timeout/src/main/java/com/iluwatar/timeout/App.java b/timeout/src/main/java/com/iluwatar/timeout/App.java index 7a0001809475..bac95da3adbd 100644 --- a/timeout/src/main/java/com/iluwatar/timeout/App.java +++ b/timeout/src/main/java/com/iluwatar/timeout/App.java @@ -34,9 +34,8 @@ * system stalls. With a limit the caller abandons the slow call, records the event and continues * with a fallback, keeping latency predictable and failures contained. * - *

The building blocks are a {@link TimeoutPolicy} per service, a {@link TimeoutRegistry} that - * makes the limits configurable in one place, and a {@link TimeoutExecutor} that enforces them, - * cancels calls that overrun, and counts timeouts in {@link TimeoutMetrics}. + *

The building blocks are a {@link TimeoutPolicy} per service and a {@link TimeoutExecutor} that + * enforces them, cancels calls that overrun, and counts timeouts in {@link TimeoutMetrics}. * *

The demo wires two services with different limits. The product catalog answers well within its * 500 ms budget and returns real data. The recommendation engine needs 400 ms but is only allowed @@ -54,22 +53,25 @@ public class App { * @param args command line arguments, not used */ public static void main(String[] args) { - var registry = - new TimeoutRegistry(Duration.ofMillis(300)) - .register(TimeoutPolicy.of(ProductCatalogService.NAME, 500)) - .register(TimeoutPolicy.of(RecommendationService.NAME, 100)); + var catalog = + new DownstreamService( + "product-catalog", Duration.ofMillis(50), List.of("Laptop", "Headphones", "Monitor")); + var recommendations = + new DownstreamService( + "recommendations", + Duration.ofMillis(400), + List.of("Mechanical keyboard", "USB-C dock")); + var catalogPolicy = TimeoutPolicy.of(catalog.name(), 500); + var recommendationPolicy = TimeoutPolicy.of(recommendations.name(), 100); LOGGER.info("Configured per-service limits: catalog 500 ms, recommendations 100 ms"); - var catalog = new ProductCatalogService(Duration.ofMillis(50)); - var recommendations = new RecommendationService(Duration.ofMillis(400)); - try (var executor = new TimeoutExecutor()) { - LOGGER.info("Calling {}", ProductCatalogService.NAME); - var products = loadProducts(executor, registry, catalog); + LOGGER.info("Calling {}", catalog.name()); + var products = call(executor, catalogPolicy, catalog, List.of()); LOGGER.info("Products: {}", products); - LOGGER.info("Calling {}", RecommendationService.NAME); - var suggested = loadRecommendations(executor, registry, recommendations, "alice"); + LOGGER.info("Calling {}", recommendations.name()); + var suggested = call(executor, recommendationPolicy, recommendations, POPULAR_ITEMS); LOGGER.info("Recommendations shown to alice: {}", suggested); LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot()); @@ -77,37 +79,19 @@ public static void main(String[] args) { } /** - * Loads the catalog under its time limit, showing an empty catalog if the limit is exceeded. - * - * @param executor executor enforcing the limit - * @param registry registry holding the catalog's policy - * @param catalog the downstream catalog service - * @return the products, or an empty list on timeout - */ - static List loadProducts( - TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) { - return executor.execute( - registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of); - } - - /** - * Loads personalised recommendations under their time limit, showing popular items instead if the - * limit is exceeded. + * Calls a service under its time limit, answering with the fallback if the limit is exceeded. * * @param executor executor enforcing the limit - * @param registry registry holding the recommendation service's policy - * @param recommendations the downstream recommendation service - * @param customer customer to personalise for - * @return the recommendations, or the popular items on timeout + * @param policy limit that applies to the service + * @param service the downstream service to call + * @param fallback answer to use when the limit is exceeded + * @return the response of the service, or the fallback on timeout */ - static List loadRecommendations( + static List call( TimeoutExecutor executor, - TimeoutRegistry registry, - RecommendationService recommendations, - String customer) { - return executor.execute( - registry.policyFor(RecommendationService.NAME), - () -> recommendations.recommendationsFor(customer), - () -> POPULAR_ITEMS); + TimeoutPolicy policy, + DownstreamService service, + List fallback) { + return executor.execute(policy, service::fetch, () -> fallback); } } diff --git a/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java b/timeout/src/main/java/com/iluwatar/timeout/DownstreamService.java similarity index 59% rename from timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java rename to timeout/src/main/java/com/iluwatar/timeout/DownstreamService.java index cdaf8fb64c04..15cbbdf690ef 100644 --- a/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java +++ b/timeout/src/main/java/com/iluwatar/timeout/DownstreamService.java @@ -29,45 +29,55 @@ import lombok.extern.slf4j.Slf4j; /** - * Simulated recommendation engine. It is slow, so it demonstrates what happens when a dependency - * misses its limit: the call is interrupted and the caller continues with a fallback. + * Simulated downstream service used by the demo. + * + *

The simulated latency decides whether the service answers within the {@link TimeoutPolicy} of + * the caller or misses it. The sleep is interruptible, so the interrupt sent by {@link + * TimeoutExecutor} actually stops the work instead of leaving it running in the background. */ @Slf4j -public class RecommendationService { - - /** Name under which the service is registered in the {@link TimeoutRegistry}. */ - public static final String NAME = "recommendations"; +public class DownstreamService { + private final String name; private final Duration latency; + private final List items; /** * Creates the service. * + * @param name name the service is known by * @param latency simulated response time + * @param items payload returned once the simulated call completes */ - public RecommendationService(Duration latency) { + public DownstreamService(String name, Duration latency, List items) { + this.name = name; this.latency = latency; + this.items = List.copyOf(items); + } + + /** + * Returns the name the service is known by. + * + * @return the service name + */ + public String name() { + return name; } /** - * Computes personalised recommendations for a customer. + * Answers the call after the simulated latency. * - * @param customer customer identifier - * @return recommended product names - * @throws InterruptedException if the call is cancelled before the computation finishes + * @return the payload of the service + * @throws InterruptedException if the call is cancelled before the simulated latency elapses */ - public List recommendationsFor(String customer) throws InterruptedException { - LOGGER.info( - "{}: computing recommendations for {}, expected latency {} ms", - NAME, - customer, - latency.toMillis()); + public List fetch() throws InterruptedException { + LOGGER.info("{}: responding, expected latency {} ms", name, latency.toMillis()); try { Thread.sleep(latency); } catch (InterruptedException e) { - LOGGER.info("{}: interrupted, abandoning the computation for {}", NAME, customer); + LOGGER.info("{}: interrupted, abandoning the call", name); throw e; } - return List.of("Mechanical keyboard", "USB-C dock"); + return items; } } diff --git a/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java b/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java deleted file mode 100644 index d16d6d7019a5..000000000000 --- a/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). - * - * The MIT License - * Copyright © 2014-2022 Ilkka Seppälä - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -package com.iluwatar.timeout; - -import java.time.Duration; -import java.util.List; -import lombok.extern.slf4j.Slf4j; - -/** - * Simulated product catalog service. It answers quickly, so its calls normally complete well within - * their limit. - */ -@Slf4j -public class ProductCatalogService { - - /** Name under which the service is registered in the {@link TimeoutRegistry}. */ - public static final String NAME = "product-catalog"; - - private final Duration latency; - - /** - * Creates the service. - * - * @param latency simulated response time - */ - public ProductCatalogService(Duration latency) { - this.latency = latency; - } - - /** - * Lists the products in the catalog. - * - * @return product names - * @throws InterruptedException if the call is cancelled while waiting for the simulated backend - */ - public List fetchProducts() throws InterruptedException { - LOGGER.info("{}: fetching products, expected latency {} ms", NAME, latency.toMillis()); - Thread.sleep(latency); - return List.of("Laptop", "Headphones", "Monitor"); - } -} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java index 33f248b1aff9..fd61c586a58a 100644 --- a/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java @@ -29,6 +29,8 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Supplier; @@ -71,13 +73,15 @@ public TimeoutExecutor(ExecutorService executor) { * @param fallback answer to use when the call does not complete in time * @param type of the response * @return the response of the call, or the fallback if the limit was exceeded - * @throws ServiceCallException if the call fails or the waiting thread is interrupted + * @throws ServiceCallException if the call cannot be submitted, fails, or the waiting thread is + * interrupted */ public T execute(TimeoutPolicy policy, Callable call, Supplier fallback) { var serviceName = policy.serviceName(); var limitMillis = policy.timeout().toMillis(); - var future = executor.submit(call); + Future future = null; try { + future = executor.submit(call); var result = future.get(limitMillis, TimeUnit.MILLISECONDS); LOGGER.info("{} responded within its {} ms limit", serviceName, limitMillis); return result; @@ -93,6 +97,8 @@ public T execute(TimeoutPolicy policy, Callable call, Supplier fallbac future.cancel(true); Thread.currentThread().interrupt(); throw new ServiceCallException(serviceName, e); + } catch (RejectedExecutionException e) { + throw new ServiceCallException(serviceName, e); } } diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java index bcc820ef3250..b7812838536e 100644 --- a/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java @@ -24,7 +24,8 @@ */ package com.iluwatar.timeout; -import java.util.Map; +import java.util.Collections; +import java.util.SortedMap; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -63,9 +64,9 @@ public int timeoutCount(String serviceName) { * * @return service name to timeout count */ - public Map snapshot() { + public SortedMap snapshot() { var snapshot = new TreeMap(); timeouts.forEach((name, counter) -> snapshot.put(name, counter.get())); - return Map.copyOf(snapshot); + return Collections.unmodifiableSortedMap(snapshot); } } diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java deleted file mode 100644 index 67b712406983..000000000000 --- a/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). - * - * The MIT License - * Copyright © 2014-2022 Ilkka Seppälä - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -package com.iluwatar.timeout; - -import java.time.Duration; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Holds the {@link TimeoutPolicy} configured for each downstream service. - * - *

Services that have no explicit policy fall back to a default limit, so callers never have to - * hard code a duration next to the call site. - */ -public class TimeoutRegistry { - - private final Map policies = new ConcurrentHashMap<>(); - private final Duration defaultTimeout; - - /** - * Creates a registry. - * - * @param defaultTimeout limit applied to services without an explicit policy - */ - public TimeoutRegistry(Duration defaultTimeout) { - this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout"); - } - - /** - * Registers or replaces the policy of a service. - * - * @param policy the policy to store - * @return this registry for chaining - */ - public TimeoutRegistry register(TimeoutPolicy policy) { - policies.put(policy.serviceName(), policy); - return this; - } - - /** - * Looks up the policy of a service. - * - * @param serviceName name of the downstream service - * @return the registered policy, or one built from the default limit - */ - public TimeoutPolicy policyFor(String serviceName) { - return policies.getOrDefault(serviceName, new TimeoutPolicy(serviceName, defaultTimeout)); - } -} diff --git a/timeout/src/test/java/com/iluwatar/timeout/AppTest.java b/timeout/src/test/java/com/iluwatar/timeout/AppTest.java index 8c77b5cf5bff..3f433cdc9e14 100644 --- a/timeout/src/test/java/com/iluwatar/timeout/AppTest.java +++ b/timeout/src/test/java/com/iluwatar/timeout/AppTest.java @@ -34,8 +34,9 @@ class AppTest { - private static final TimeoutRegistry GENEROUS = new TimeoutRegistry(Duration.ofSeconds(5)); - private static final TimeoutRegistry STRICT = new TimeoutRegistry(Duration.ofMillis(50)); + private static final TimeoutPolicy GENEROUS = TimeoutPolicy.of("catalog", 5_000); + private static final TimeoutPolicy STRICT = TimeoutPolicy.of("catalog", 50); + private static final List FALLBACK = List.of("Webcam"); @Test void shouldLaunchApp() { @@ -48,44 +49,20 @@ void shouldBeInstantiable() { } @Test - void loadsProductsWithinLimit() { - try (var executor = new TimeoutExecutor()) { - var products = - App.loadProducts(executor, GENEROUS, new ProductCatalogService(Duration.ofMillis(1))); - - assertEquals(List.of("Laptop", "Headphones", "Monitor"), products); - } - } + void returnsTheResponseOfAServiceThatAnswersWithinItsLimit() { + var service = new DownstreamService("catalog", Duration.ofMillis(1), List.of("Laptop")); - @Test - void showsEmptyCatalogWhenCatalogExceedsLimit() { try (var executor = new TimeoutExecutor()) { - var products = - App.loadProducts(executor, STRICT, new ProductCatalogService(Duration.ofSeconds(60))); - - assertEquals(List.of(), products); + assertEquals(List.of("Laptop"), App.call(executor, GENEROUS, service, FALLBACK)); } } @Test - void loadsRecommendationsWithinLimit() { - try (var executor = new TimeoutExecutor()) { - var suggested = - App.loadRecommendations( - executor, GENEROUS, new RecommendationService(Duration.ofMillis(1)), "alice"); - - assertEquals(List.of("Mechanical keyboard", "USB-C dock"), suggested); - } - } + void returnsTheFallbackWhenTheServiceExceedsItsLimit() { + var service = new DownstreamService("catalog", Duration.ofSeconds(60), List.of("Laptop")); - @Test - void showsPopularItemsWhenRecommendationsExceedLimit() { try (var executor = new TimeoutExecutor()) { - var suggested = - App.loadRecommendations( - executor, STRICT, new RecommendationService(Duration.ofSeconds(60)), "alice"); - - assertEquals(List.of("Wireless mouse", "Webcam"), suggested); + assertEquals(FALLBACK, App.call(executor, STRICT, service, FALLBACK)); } } } diff --git a/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java b/timeout/src/test/java/com/iluwatar/timeout/DownstreamServiceTest.java similarity index 76% rename from timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java rename to timeout/src/test/java/com/iluwatar/timeout/DownstreamServiceTest.java index 0b838956a8ff..b1c5a0347388 100644 --- a/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java +++ b/timeout/src/test/java/com/iluwatar/timeout/DownstreamServiceTest.java @@ -31,22 +31,23 @@ import java.util.List; import org.junit.jupiter.api.Test; -class RecommendationServiceTest { +class DownstreamServiceTest { @Test - void returnsRecommendationsAfterSimulatedLatency() throws InterruptedException { - var service = new RecommendationService(Duration.ofMillis(1)); + void returnsItsItemsAfterTheSimulatedLatency() throws InterruptedException { + var service = new DownstreamService("catalog", Duration.ofMillis(1), List.of("Laptop")); - assertEquals(List.of("Mechanical keyboard", "USB-C dock"), service.recommendationsFor("alice")); + assertEquals("catalog", service.name()); + assertEquals(List.of("Laptop"), service.fetch()); } @Test - void abandonsComputationWhenInterrupted() { - var service = new RecommendationService(Duration.ofSeconds(60)); + void abandonsTheCallWhenInterrupted() { + var service = new DownstreamService("catalog", Duration.ofSeconds(60), List.of("Laptop")); Thread.currentThread().interrupt(); try { - assertThrows(InterruptedException.class, () -> service.recommendationsFor("alice")); + assertThrows(InterruptedException.class, service::fetch); } finally { Thread.interrupted(); } diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java index 98b92b8c3b4a..73e427dae21e 100644 --- a/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java +++ b/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java @@ -30,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; @@ -129,6 +130,17 @@ void countsTimeoutsPerService() { assertEquals(Map.of("strict", 2, "other", 1), executor.metrics().snapshot()); } + @Test + void keepsSnapshotSortedByServiceName() { + var metrics = executor.metrics(); + metrics.recordTimeout("payments"); + metrics.recordTimeout("analytics"); + metrics.recordTimeout("catalog"); + + assertEquals( + List.of("analytics", "catalog", "payments"), List.copyOf(metrics.snapshot().keySet())); + } + @Test void propagatesInterruptionOfTheCaller() { Callable slowCall = @@ -152,8 +164,12 @@ void propagatesInterruptionOfTheCaller() { void rejectsCallsAfterClose() { executor.close(); - assertThrows( - RejectedExecutionException.class, - () -> executor.execute(GENEROUS, () -> "ignored", () -> "fallback")); + var thrown = + assertThrows( + ServiceCallException.class, + () -> executor.execute(GENEROUS, () -> "ignored", () -> "fallback")); + + assertInstanceOf(RejectedExecutionException.class, thrown.getCause()); + assertEquals(0, executor.metrics().timeoutCount(GENEROUS.serviceName())); } } diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java deleted file mode 100644 index 069b0e39e93c..000000000000 --- a/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). - * - * The MIT License - * Copyright © 2014-2022 Ilkka Seppälä - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -package com.iluwatar.timeout; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.time.Duration; -import org.junit.jupiter.api.Test; - -class TimeoutRegistryTest { - - private final TimeoutRegistry registry = new TimeoutRegistry(Duration.ofMillis(300)); - - @Test - void returnsRegisteredPolicy() { - registry.register(TimeoutPolicy.of("catalog", 500)); - - assertEquals(TimeoutPolicy.of("catalog", 500), registry.policyFor("catalog")); - } - - @Test - void fallsBackToDefaultLimitForUnknownService() { - assertEquals(TimeoutPolicy.of("unknown", 300), registry.policyFor("unknown")); - } - - @Test - void replacesExistingPolicy() { - registry.register(TimeoutPolicy.of("catalog", 500)).register(TimeoutPolicy.of("catalog", 50)); - - assertEquals(Duration.ofMillis(50), registry.policyFor("catalog").timeout()); - } -}