diff --git a/.github/workflows/clojure.yml b/.github/workflows/clojure.yml index e2c3887..850e006 100644 --- a/.github/workflows/clojure.yml +++ b/.github/workflows/clojure.yml @@ -12,9 +12,8 @@ jobs: test: strategy: matrix: - java_version: [corretto-11,corretto-21] + java_version: [corretto-11,corretto-24] test_clojure_alias: [clojure-1.10, clojure-1.11, clojure-1.12] - test_core_async_alias: [core.async-1.6, core.async-1.7, core.async-1.8] runs-on: ubuntu-latest steps: - name: Checkout code @@ -35,8 +34,7 @@ jobs: - name: Run tests env: TEST_CLOJURE_ALIAS: ${{matrix.test_clojure_alias}} - TEST_CORE_ASYNC_ALIAS: ${{matrix.test_core_async_alias}} - run: make test TEST_CLOJURE_ALIAS=$TEST_CLOJURE_ALIAS TEST_CORE_ASYNC_ALIAS=$TEST_CORE_ASYNC_ALIAS + run: make test TEST_CLOJURE_ALIAS=$TEST_CLOJURE_ALIAS build: runs-on: ubuntu-latest steps: diff --git a/.mise.toml b/.mise.toml index d323733..178713c 100644 --- a/.mise.toml +++ b/.mise.toml @@ -2,4 +2,5 @@ clojure = "https://github.com/asdf-community/asdf-clojure.git" [tools] +java = "graalvm-22.3.1+java11" clojure = "1.12" diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e8fc3..661c235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ This is a history of changes to k13labs/failsage -# 0.0.1-SNAPSHOT - Unreleased -* ... +# 0.1.0 - 2025-10-21 + +Initial release providing idiomatic Clojure wrappers for [Failsafe.dev](https://failsafe.dev/). + +### Features + +* Policy builders: `retry`, `circuit-breaker`, `fallback`, `timeout`, `rate-limiter`, `bulkhead` +* Synchronous execution via `execute` +* Asynchronous execution via `execute-async` with futurama integration +* Policy composition support +* Event callbacks for observability +* Comprehensive test coverage across Clojure 1.10, 1.11, 1.12 +* GraalVM native image support diff --git a/Makefile b/Makefile index 1b1971b..3bafc37 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,19 @@ .PHONY: repl test clean deploy install format-check format-fix TEST_CLOJURE_ALIAS ?= clojure-1.12 -TEST_CORE_ASYNC_ALIAS ?= core.async-1.8 REPL_CLOJURE_ALIAS ?= clojure-1.12 -REPL_CORE_ASYNC_ALIAS ?= core.async-1.8 SHELL := /bin/bash -JAVA_OPTS ?= -Dfailsage.executor-factory=clojure.core.async.impl.dispatch/executor-for - -export _JAVA_OPTIONS := $(JAVA_OPTS) env: env repl: - clojure -M:$(REPL_CLOJURE_ALIAS):$(REPL_CORE_ASYNC_ALIAS):dev:test:app:repl + clojure -M:$(REPL_CLOJURE_ALIAS):dev:test:app:repl test: - clojure -M:$(TEST_CLOJURE_ALIAS):$(TEST_CORE_ASYNC_ALIAS):dev:test:app:runner \ + clojure -M:$(TEST_CLOJURE_ALIAS):dev:test:app:runner \ --focus :unit --reporter kaocha.report/documentation --no-capture-output clean: diff --git a/README.md b/README.md index 3344c89..b2e78e8 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,287 @@ # _About_ -Failsage is a Clojure library looking to provide a translation layer to use Failsafe.dev from Clojure in a way that is compatible and consistent for sync and async code via Clojure [core.async](https://github.com/clojure/core.async). +Failsage is a Clojure library that provides an idiomatic translation layer for [Failsafe.dev](https://failsafe.dev/), making it easier to use Failsafe's battle-tested resilience patterns from Clojure. + +## Rationale + +Failsage is designed for simplicity and ergonomics. You can pass policies directly to execute functions without creating executors, use optional context bindings only when you need execution state, and compose multiple policies through simple vectors. Rather than hiding context in dynamic variables or nesting macros, failsage makes state explicit and easy to test. It provides first-class async integration with [futurama](https://github.com/k13labs/futurama) using the same straightforward API. The result is a library where simple cases require minimal code and complex scenarios remain clear and composable. + +Note: You can also use Failsafe directly via Java interop or through alternatives like [diehard](https://github.com/sunng87/diehard). + +## Features + +Rather than reinventing the resilience patterns, Failsage wisely wraps Failsafe with: + +- **Idiomatic Clojure API**: Keyword arguments, immutable data structures, and functional style +- **Async Support**: Seamless async execution integration via [futurama](https://github.com/k13labs/futurama), including channels/futures/promises/deferreds. +- **Unified Interface**: Consistent API for both synchronous and asynchronous code paths + +Failsage supports all core Failsafe policies: +- **Retry**: Automatic retry with configurable backoff strategies +- **Circuit Breaker**: Prevent cascading failures by temporarily blocking requests +- **Fallback**: Graceful degradation with alternative results +- **Timeout**: Time-bound execution with cancellation support +- **Rate Limiter**: Control execution rate to prevent system overload +- **Bulkhead**: Limit concurrent executions to isolate resources + +For detailed information on each policy's behavior and configuration options, see the [Failsafe documentation](https://failsafe.dev/). # _Usage_ -Here's a simple example. +## Basic Example ```clj +(require '[failsage.core :as fs]) + +;; Simplest form - no executor needed +(fs/execute + (call-unreliable-service)) + +;; With a retry policy +(def retry-policy + (fs/retry {:max-retries 3 + :delay-ms 100 + :backoff-delay-factor 2.0})) + +;; Pass policy directly - no need to create executor +(fs/execute retry-policy + (call-unreliable-service)) + +;; Or create an executor explicitly +(def executor (fs/executor retry-policy)) +(fs/execute executor + (call-unreliable-service)) +``` + +## Synchronous Execution + +### Retry with Exponential Backoff + +```clj +(def retry-policy + (fs/retry {:max-retries 3 + :backoff-delay-ms 100 + :backoff-max-delay-ms 5000 + :backoff-delay-factor 2.0})) + +;; Pass policy directly +(fs/execute retry-policy + (http/get "https://api.example.com/data")) +``` + +### Circuit Breaker + +```clj +(def circuit-breaker + (fs/circuit-breaker {:delay-ms 60000 ;; Wait 1 minute before half-open + :failure-threshold 5 ;; Open after 5 consecutive failures + :on-open-fn (fn [e] (log/warn "Circuit breaker opened!")) + :on-close-fn (fn [e] (log/info "Circuit breaker closed!"))})) + +;; Pass policy directly +(fs/execute circuit-breaker + (call-flaky-service)) ``` -See the existing tests for more examples. +### Fallback + +```clj +(def fallback-policy + (fs/fallback {:result {:status :degraded :data []} + :handle-exception Exception})) + +;; Returns fallback value on any exception +(fs/execute fallback-policy + (fetch-user-data user-id)) +;; => {:status :degraded :data []} +``` + +### Timeout + +```clj +(def timeout-policy + (fs/timeout {:timeout-ms 5000 ;; 5 second timeout + :interrupt true})) ;; Interrupt thread on timeout + +;; Pass policy directly +(fs/execute timeout-policy + (slow-database-query)) +``` + +### Rate Limiting + +```clj +;; Allow 100 requests per second +(def rate-limiter + (fs/rate-limiter {:max-executions 100 + :period-ms 1000 + :burst true})) + +;; Pass policy directly +(fs/execute rate-limiter + (process-request request)) +``` + +### Bulkhead + +```clj +;; Limit to 10 concurrent executions +(def bulkhead-policy + (fs/bulkhead {:max-concurrency 10 + :max-wait-time-ms 1000})) ;; Wait up to 1 second for permit + +;; Pass policy directly +(fs/execute bulkhead-policy + (process-task task)) +``` + +## Policy Composition + +Policies can be composed together. They are applied in order from innermost to outermost: + +```clj +;; Compose retry, circuit breaker, and fallback +;; Execution order: retry -> circuit breaker -> fallback +(def retry-policy (fs/retry {:max-retries 3})) +(def cb-policy (fs/circuit-breaker {:failure-threshold 5 :delay-ms 60000})) +(def fallback-policy (fs/fallback {:result :fallback-value})) + +;; Policies compose: fallback wraps circuit-breaker wraps retry +(def executor (fs/executor [fallback-policy cb-policy retry-policy])) + +(fs/execute executor + (unreliable-operation)) +;; - First tries the operation +;; - Retries up to 3 times on failure +;; - Circuit breaker tracks failures +;; - If everything fails, fallback returns :fallback-value +``` + +### Common Composition Patterns + +**Timeout + Retry**: Prevent long waits while retrying +```clj +(def timeout-policy (fs/timeout {:timeout-ms 2000})) +(def retry-policy (fs/retry {:max-retries 3 :delay-ms 100})) + +;; Timeout applies to EACH retry attempt +(def executor (fs/executor [retry-policy timeout-policy])) +``` + +**Bulkhead + Circuit Breaker + Fallback**: Complete resilience stack +```clj +(def bulkhead (fs/bulkhead {:max-concurrency 20})) +(def cb (fs/circuit-breaker {:failure-threshold 10 :delay-ms 30000})) +(def fallback (fs/fallback {:result {:status :degraded}})) + +;; Limit concurrency, break on failures, degrade gracefully +(def executor (fs/executor [fallback cb bulkhead])) +``` + +## Asynchronous Execution + +Failsage integrates with [futurama](https://github.com/k13labs/futurama) for async execution: + +```clj +(require '[failsage.core :as fs]) +(require '[futurama.core :as f]) + +;; Simplest form - uses default thread pool +(fs/execute-async + (f/async + (let [result (f/com.github.k13labs failsage failsage - 0.0.1-SNAPSHOT + 0.1.0 - 0.0.1-rc.1 + 0.1.0 https://github.com/k13labs/failsage scm:git:git://github.com/k13labs/failsage.git scm:git:ssh://git@github.com/k13labs/failsage.git @@ -22,7 +22,7 @@ org.clojure clojure - 1.12.0 + 1.12.3 dev.failsafe @@ -34,11 +34,6 @@ futurama 1.3.1 - - org.clojure - core.async - 1.8.735 - src diff --git a/resources/clj-kondo.exports/failsage/failsage/config.edn b/resources/clj-kondo.exports/failsage/failsage/config.edn index 0967ef4..0177e52 100644 --- a/resources/clj-kondo.exports/failsage/failsage/config.edn +++ b/resources/clj-kondo.exports/failsage/failsage/config.edn @@ -1 +1,5 @@ -{} +{:hooks {:analyze-call {failsage.core/execute + hooks.failsage/analyze-execute-macro + + failsage.core/execute-async + hooks.failsage/analyze-execute-macro}}} diff --git a/resources/clj-kondo.exports/failsage/failsage/hooks/failsage.clj_kondo b/resources/clj-kondo.exports/failsage/failsage/hooks/failsage.clj_kondo new file mode 100644 index 0000000..4554220 --- /dev/null +++ b/resources/clj-kondo.exports/failsage/failsage/hooks/failsage.clj_kondo @@ -0,0 +1,17 @@ +(ns hooks.failsage + (:require [clj-kondo.hooks-api :as api])) + +(defn analyze-execute-macro + "analyze execute macro to support optional context binding" + [{:keys [:node]}] + (let [execute-args (rest (:children node)) + [executor-or-policy context-binding body] (when (= 3 (count execute-args)) + execute-args)] + (if context-binding + {:node (api/list-node + (list + (api/token-node 'let) + (api/vector-node + (vector context-binding executor-or-policy)) + body))} + {:node node}))) diff --git a/src/failsage/core.clj b/src/failsage/core.clj index da85520..6771426 100644 --- a/src/failsage/core.clj +++ b/src/failsage/core.clj @@ -1,2 +1,562 @@ (ns failsage.core - "Failsage is a Clojure library looking to provide a translation layer to use Failsafe.dev from Clojure in a way that is compatible and consistent for sync/async code") + "Defines failsafe policies for handling failures, retries, etc." + (:require + [failsage.impl :as impl] + [futurama.core :refer [! (Bulkhead/builder (int max-concurrency)) + max-wait-time-ms (.withMaxWaitTime (Duration/ofMillis (long max-wait-time-ms))) + + on-success-fn + ^BulkheadBuilder (.onSuccess (impl/->event-listener on-success-fn)) + + on-failure-fn + ^BulkheadBuilder (.onFailure (impl/->event-listener on-failure-fn)))] + (if build + (build-policy builder) + builder))) + +(defn circuit-breaker + "A circuit breaker temporarily blocks execution when a configured number of failures are exceeded. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/CircuitBreaker.html + + Circuit breakers have three states: closed, open, and half-open: + - When a circuit breaker is in the closed (initial) state, executions are allowed. + - If a configurable number of failures occur, optionally over some time period, the circuit breaker transitions to the open state. + - In the open state a circuit breaker will fail executions with CircuitBreakerOpenException. + - After a configurable delay, the circuit breaker will transition to a half-open state. + - In the half-open state a configurable number of trial executions will be allowed, after which the circuit breaker will + transition back to closed or open depending on how many were successful. + + A circuit breaker can be count based or time based: + - Count based circuit breakers will transition between states when recent execution results exceed a threshold. + - Time based circuit breakers will transition between states when recent execution results exceed a threshold within a time period. + + A minimum number of executions must be performed in order for a state transition to occur: + - Time based circuit breakers use a sliding window to aggregate execution results. + - The window is divided into 10 time slices, each representing 1/10th of the failure thresholding period`. + - As time progresses, statistics for old time slices are gradually discarded, which smoothes the calculation of success and failure rates. + + Base options: + - `:build` (optional): If true, builds and returns the CircuitBreaker instance. If false, returns the CircuitBreakerBuilder instance. Default is true. + - `:on-open-fn` (optional): A function that takes a single CircuitBreakerStateChangedEvent argument, called when the circuit transitions to OPEN state. + - `:on-half-open-fn` (optional): A function that takes a single CircuitBreakerStateChangedEvent argument, called when the circuit transitions to HALF_OPEN state. + - `:on-close-fn` (optional): A function that takes a single CircuitBreakerStateChangedEvent argument, called when the circuit transitions to CLOSED state. + - `:on-success-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes successfully. + - `:on-failure-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes with a failure. + - `:handle-exception` (optional): A class or collection of exception classes that should be considered failures by the circuit breaker. + - `:handle-exception-fn` (optional): A function that takes a single Throwable argument, and returns true if the exception should be considered a failure by the circuit breaker. + - `:handle-result` (optional): A static result that should be considered a failure by the circuit breaker. + - `:handle-result-fn` (optional): A function that takes a single result argument, and returns true if the result should be considered a failure by the circuit breaker. + - `:delay-ms`: Delay to wait in OPEN state before transitioning to half-open. + + Failure threshold options: + 1. Configures time based failure rate thresholding by setting the `failure-rate-threshold`, from 1 to 100, that must occur within + the rolling `failure-thresholding-period-ms` when in a CLOSED state in order to open the circuit. The number of executions must also + exceed the `failure-execution-threshold` within the `failure-thresholding-period-ms` before the circuit can be opened. + - `:failure-rate-threshold`: The percentage rate of failures, from 1 to 100, that must occur in order to open the circuit. + - `:failure-execution-threshold`: The minimum number of executions that must occur within the `failure-thresholding-period-ms`. + when in the CLOSED state before the circuit can be opened, or in the HALF_OPEN state before it can be re-opened or closed. + - `:failure-thresholding-period-ms`: The period during which failures are compared to the `failure-threshold`. + + 2. Configures count based failure thresholding by setting the number of consecutive failures that must occur when in a CLOSED state in order to open the circuit. + - `:failure-threshold`: The number of consecutive failures that must occur in order to open the circuit. + + 3. Configures count based failure thresholding by setting the ratio of failures to executions that must occur when in a CLOSED state in order to open the circuit. + For example: 5, 10 would open the circuit if 5 out of the last 10 executions result in a failure. + - `:failure-threshold`: The number of failures that must occur in order to open the circuit. + - `:failure-thresholding-capacity`: Configures count based failure thresholding by setting the ratio of failed executions. + + 4. Configures time based failure thresholding by setting the number of failures that must occur within the `failure-thresholding-period-ms` + when in a CLOSED state in order to open the circuit. + - `:failure-threshold`: The number of failures that must occur within the `failure-thresholding-period-ms` in order to open the circuit. + - `:failure-thresholding-period-ms`: The period during which failures are compared to the `failure-threshold`. + + 5. Configures time based failure thresholding by setting the number of failures that must occur within the `failure-thresholding-period-ms` + when in a CLOSED state in order to open the circuit. The number of executions must also exceed the `failure-execution-threshold` within + the `failure-thresholding-period-ms` when in the CLOSED state before the circuit can be opened. + - `:failure-threshold`: The number of failures that must occur within the `failure-thresholding-period-ms` in order to open the circuit. + - `:failure-execution-threshold`: The minimum number of executions that must occur within the `failure-thresholding-period-ms` + when in the CLOSED state before the circuit can be opened. + - `:failure-thresholding-period-ms`: The period during which failures are compared to the `failure-threshold`. + + + Success threshold options: + If a success threshold is not configured, the the failure threshold configuration will also be used when the circuit breaker + is in a HALF_OPEN state to determine whether to transition back to OPEN or CLOSED. + - `:success-threshold` (optional): Configures count based success thresholding by setting the number of consecutive successful executions + that must occur when in a HALF_OPEN state in order to close the circuit, else the circuit is re-opened when a failure occurs. + - `:success-thresholding-capacity` (optional): Configures count based success thresholding by setting the ratio of successful executions + that must occur when in a HALF_OPEN state in order to close the circuit. For example: 5, 10 would close the circuit + if 5 out of the last 10 executions were successful. + + Exceptions: + - `dev.failsafe.CircuitBreakerOpenException` - if the circuit breaker is in a half-open state and no permits remain + according to the configured success or failure thresholding capacity." + [{:keys [build + delay-ms + on-open-fn + on-half-open-fn + on-close-fn + on-success-fn + on-failure-fn + handle-exception + handle-exception-fn + handle-result + handle-result-fn + failure-threshold + failure-rate-threshold + failure-execution-threshold + failure-thresholding-period-ms + failure-thresholding-capacity + success-threshold + success-thresholding-capacity] + :or {build true}}] + (let [;;; Apply base options + builder (cond-> (CircuitBreaker/builder) + delay-ms + (.withDelay (Duration/ofMillis (long delay-ms))) + + on-success-fn + ^CircuitBreakerBuilder (.onSuccess (impl/->event-listener on-success-fn)) + + on-failure-fn + ^CircuitBreakerBuilder (.onFailure (impl/->event-listener on-failure-fn)) + + handle-exception + ^CircuitBreakerBuilder (.handle ^"[Ljava.lang.Class;" (into-array Class (flatten [handle-exception]))) + + handle-exception-fn + ^CircuitBreakerBuilder (.handleIf ^CheckedPredicate (impl/->checked-predicate handle-exception-fn)) + + handle-result + ^CircuitBreakerBuilder (.handleResult ^Object handle-result) + + handle-result-fn + ^CircuitBreakerBuilder (.handleResultIf ^CheckedPredicate (impl/->checked-predicate handle-result-fn)) + + on-open-fn + (.onOpen (impl/->event-listener on-open-fn)) + + on-half-open-fn + (.onHalfOpen (impl/->event-listener on-half-open-fn)) + + on-close-fn + (.onClose (impl/->event-listener on-close-fn))) + ;;; Apply success threshold options + builder (cond + (and success-threshold + success-thresholding-capacity) + (.withSuccessThreshold builder (int success-threshold) (int success-thresholding-capacity)) + + success-threshold + (.withSuccessThreshold builder (int success-threshold)) + + :else builder) + ;;; Apply failure threshold options + builder (cond + (and failure-rate-threshold + failure-execution-threshold + failure-thresholding-period-ms) + (.withFailureRateThreshold builder (int failure-rate-threshold) (int failure-execution-threshold) (Duration/ofMillis (long failure-thresholding-period-ms))) + + (and failure-threshold + failure-execution-threshold + failure-thresholding-period-ms) + (.withFailureThreshold builder (int failure-threshold) (int failure-execution-threshold) (Duration/ofMillis (long failure-thresholding-period-ms))) + + (and failure-threshold + failure-thresholding-capacity) + (.withFailureThreshold builder (int failure-threshold) (int failure-thresholding-capacity)) + + (and failure-threshold + failure-thresholding-period-ms) + (.withFailureThreshold builder (int failure-threshold) (Duration/ofMillis (long failure-thresholding-period-ms))) + + failure-threshold + (.withFailureThreshold builder (int failure-threshold)) + + :else builder)] + (if build + (build-policy builder) + builder))) + +(defn fallback + "A Policy that handles failures using a fallback function or result. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/Fallback.html + + Options: + - `:build` (optional): If true, builds and returns the Fallback instance. If false, returns the FallbackBuilder instance. Default is true. + - `:fallback-fn` (optional): A function that takes a single ExecutionAttemptedEvent argument, and returns a fallback result. + - `:fallback` (optional): A static fallback result to return when a failure occurs. + - `:on-success-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes successfully. + - `:on-failure-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes with a failure. + - `:handle-exception` (optional): A class or collection of exception classes that should be considered failures by the circuit breaker. + - `:handle-exception-fn` (optional): A function that takes a single Throwable argument, and returns true if the exception should be considered a failure by the circuit breaker. + - `:handle-result` (optional): A static result that should be considered a failure by the circuit breaker. + - `:handle-result-fn` (optional): A function that takes a single result argument, and returns true if the result should be considered a failure by the circuit breaker." + [{:keys [build + result + result-fn + on-success-fn + on-failure-fn + handle-exception + handle-exception-fn + handle-result + handle-result-fn] + :or {build true}}] + (let [^Object none nil + ;;; Apply fallback options + builder (cond + (some? result-fn) + (Fallback/builder (impl/->checked-function result-fn)) + + (some? result) + (Fallback/builder result) + + :else + (Fallback/builder none)) + ;;; Apply base options + builder (cond-> builder + on-success-fn + ^FallbackBuilder (.onSuccess (impl/->event-listener on-success-fn)) + + on-failure-fn + ^FallbackBuilder (.onFailure (impl/->event-listener on-failure-fn)) + + handle-exception + ^FallbackBuilder (.handle ^"[Ljava.lang.Class;" (into-array Class (flatten [handle-exception]))) + + handle-exception-fn + ^FallbackBuilder (.handleIf ^CheckedPredicate (impl/->checked-predicate handle-exception-fn)) + + handle-result + ^FallbackBuilder (.handleResult ^Object handle-result) + + handle-result-fn + ^FallbackBuilder (.handleResultIf ^CheckedPredicate (impl/->checked-predicate handle-result-fn)))] + (if build + (build-policy builder) + builder))) + +(defn rate-limiter + "A rate limiter allows you to control the rate of executions as a way of preventing system overload. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/RateLimiter.html + + Options: + - `:build` (optional): If true, builds and returns the RateLimiter instance. If false, returns the RateLimiterBuilder instance. Default is true. + - `:burst` (optional): If true, enables bursting behavior. Default is false. + - `:max-wait-time-ms` (optional): Maximum time in milliseconds to wait for a permit if none are available. Default is 0 (no wait). + - `:max-executions` (required): Maximum number of executions allowed within the specified period. + - `:period-ms` (required): Time period in milliseconds for the max executions. + - `:on-success-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes successfully. + - `:on-failure-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes with a failure. + + Exceptions: + `dev.failsafe.RateLimitExceededException` - if the rate limiter cannot acquire a permit within the `max-wait-time-ms`" + [{:keys [build + burst + max-wait-time-ms + max-executions + period-ms + on-success-fn + on-failure-fn] + :or {build true + burst false + max-wait-time-ms 0}}] + (let [;;; Create a base rate limiter builder + builder (if burst + (RateLimiter/burstyBuilder (long max-executions) (Duration/ofMillis (long period-ms))) + (RateLimiter/smoothBuilder (long max-executions) (Duration/ofMillis (long period-ms)))) + ;;; Apply options + builder (cond-> builder + max-wait-time-ms + (.withMaxWaitTime (Duration/ofMillis (long max-wait-time-ms))) + on-success-fn + ^RateLimiterBuilder (.onSuccess (impl/->event-listener on-success-fn)) + + on-failure-fn + ^RateLimiterBuilder (.onFailure (impl/->event-listener on-failure-fn)))] + (if build + (build-policy builder) + builder))) + +(defn retry + "A policy that defines when retries should be performed. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/RetryPolicy.html + + Options: + - `:build` (optional): If true, builds and returns the Bulkhead instance. If false, returns the RetryPolicyBuilder instance. Default is true. + - `:backoff-delay-ms` (optional): Sets the `backoff-delay-ms` between retries. + - `:backoff-max-delay-ms` (optional): Sets the maximum `backoff-max-delay-ms` between retries. + - `:backoff-delay-factor` (optional): Sets the `backoff-delay-factor` to multiply consecutive delays by. Default is 2.0. + - `:delay-ms` (optional): Sets a fixed `delay-ms` between retries. + - `:min-delay-ms` (optional): Sets the minimum `min-delay-ms` between retries. + - `:max-delay-ms` (optional): Sets the maximum `max-delay-ms` between retries. + - `:jitter-ms` (optional): Sets the `jitter-ms` to randomly vary delays between retries. + - `:jitter-factor` (optional): Sets the `jitter-factor` to randomly vary delays between retries by multiplying delays by the factor. + - `:max-retries` (optional): Sets the maximum number of retries to perform when there is a failure, -1 means unlimited. + - `:max-attempts` (optional): Sets the maximum number of attempts, including the initial attempt, -1 means unlimited. + - `:max-duration-ms` (optional): Sets the max duration to perform retries for, else the execution will be failed. + - `:abort-if-result` (optional): A static result that, if returned, will abort further retries. + - `:abort-if-result-fn` (optional): A function that takes a single result argument, and returns true if further retries should be aborted. + - `:abort-on-exception` (optional): A class or collection of exception classes that, if thrown, will abort further retries. + - `:abort-on-exception-fn` (optional): A function that takes a single Throwable argument, and returns true if further retries should be aborted. + - `:on-abort-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when retries are aborted. + - `:on-failed-attempt-fn` (optional): A function that takes a single ExecutionAttemptedEvent argument, called when a retryable attempt fails. + - `:on-retries-exceeded-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when the maximum retries have been exceeded. + - `:on-retry-fn` (optional): A function that takes a single ExecutionAttemptedEvent argument, called before a retry attempt is made. + - `:on-retry-scheduled-fn` (optional): A function that takes a single RetryScheduledEvent argument, called when an async retry has been scheduled. + - `:on-success-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes successfully. + - `:on-failure-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes with a failure. + - `:handle-exception` (optional): A class or collection of exception classes that should be considered failures by the circuit breaker. + - `:handle-exception-fn` (optional): A function that takes a single Throwable argument, and returns true if the exception should be considered a failure by the circuit breaker. + - `:handle-result` (optional): A static result that should be considered a failure by the circuit breaker. + - `:handle-result-fn` (optional): A function that takes a single result argument, and returns true if the result should be considered a failure by the circuit breaker. + + Exceptions:" + [{:keys [build + backoff-delay-ms + backoff-max-delay-ms + backoff-delay-factor + delay-ms + min-delay-ms + max-delay-ms + jitter-ms + jitter-factor + max-retries + max-attempts + max-duration-ms + abort-if-result + abort-if-result-fn + abort-on-exception + abort-on-exception-fn + on-abort-fn + on-failed-attempt-fn + on-retries-exceeded-fn + on-retry-fn + on-retry-scheduled-fn + on-success-fn + on-failure-fn + handle-exception + handle-exception-fn + handle-result + handle-result-fn] + :or {build true}}] + (let [builder (cond-> (RetryPolicy/builder) + (and backoff-delay-ms backoff-max-delay-ms) + (.withBackoff (Duration/ofMillis (long backoff-delay-ms)) + (Duration/ofMillis (long backoff-max-delay-ms)) + (double (or backoff-delay-factor 2.0))) + + delay-ms + (.withDelay (Duration/ofMillis (long delay-ms))) + + (and min-delay-ms max-delay-ms) + (.withDelay (Duration/ofMillis (long min-delay-ms)) + (Duration/ofMillis (long max-delay-ms))) + jitter-ms + (.withJitter (Duration/ofMillis (long jitter-ms))) + + jitter-factor + (.withJitter (double jitter-factor)) + + max-retries + (.withMaxRetries (int max-retries)) + + max-attempts + (.withMaxAttempts (int max-attempts)) + + max-duration-ms + (.withMaxDuration (Duration/ofMillis (long max-duration-ms))) + + abort-if-result + (.abortWhen ^Object abort-if-result) + + abort-if-result-fn + (.abortIf ^CheckedPredicate (impl/->checked-predicate abort-if-result-fn)) + + abort-on-exception + (.abortOn ^"[Ljava.lang.Class;" (into-array Class (flatten [abort-on-exception]))) + + abort-on-exception-fn + (.abortOn ^CheckedPredicate (impl/->checked-predicate abort-on-exception-fn)) + + on-abort-fn + (.onAbort (impl/->event-listener on-abort-fn)) + + on-failed-attempt-fn + (.onFailedAttempt (impl/->event-listener on-failed-attempt-fn)) + + on-retries-exceeded-fn + (.onRetriesExceeded (impl/->event-listener on-retries-exceeded-fn)) + + on-retry-fn + (.onRetry (impl/->event-listener on-retry-fn)) + + on-retry-scheduled-fn + (.onRetryScheduled (impl/->event-listener on-retry-scheduled-fn)) + + on-success-fn + ^RetryPolicyBuilder (.onSuccess (impl/->event-listener on-success-fn)) + + on-failure-fn + ^RetryPolicyBuilder (.onFailure (impl/->event-listener on-failure-fn)) + + handle-exception + ^RetryPolicyBuilder (.handle ^"[Ljava.lang.Class;" (into-array Class (flatten [handle-exception]))) + + handle-exception-fn + ^RetryPolicyBuilder (.handleIf ^CheckedPredicate (impl/->checked-predicate handle-exception-fn)) + + handle-result + ^RetryPolicyBuilder (.handleResult ^Object handle-result) + + handle-result-fn + ^RetryPolicyBuilder (.handleResultIf ^CheckedPredicate (impl/->checked-predicate handle-result-fn)))] + (if build + (build-policy builder) + builder))) + +(defn timeout + "A policy that cancels and fails an execution with a TimeoutExceededException if a timeout is exceeded. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/Timeout.html + + Options: + - `:build` (optional): If true, builds and returns the Timeout instance. If false, returns the TimeoutBuilder instance. Default is true. + - `:on-success-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes successfully. + - `:on-failure-fn` (optional): A function that takes a single ExecutionCompletedEvent argument, called when an execution completes with a failure. + - `:timeout-ms` (required): The timeout duration in milliseconds. + - `:interrupt` (optional): Configures the policy to interrupt an execution in addition to cancelling it when the timeout is exceeded.. Default is false." + [{:keys [build + timeout-ms + interrupt + on-success-fn + on-failure-fn] + :or {build true + interrupt false}}] + (let [builder (cond-> (Timeout/builder (Duration/ofMillis (long timeout-ms))) + interrupt + (.withInterrupt) + + on-success-fn + ^TimeoutBuilder (.onSuccess (impl/->event-listener on-success-fn)) + + on-failure-fn + ^TimeoutBuilder (.onFailure (impl/->event-listener on-failure-fn)))] + (if build + (build-policy builder) + builder))) + +(defn executor + "Creates a FailsafeExecutor instance with the provided policies. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/FailsafeExecutor.html + + Options: + - `:pool` (optional): An executor service to use for async executions. + If not provided, it will use the currently bound `futurama.core/*thread-pool*` or `:io` pool. + - `:policies`, `:policy-args` (optional): The policy or policies to use with the FailsafeExecutor. + If not provided, the executor will treat any exceptions as a failure without any additional error handling." + (^FailsafeExecutor [] + (impl/->executor nil nil)) + (^FailsafeExecutor [policies] + (impl/->executor nil policies)) + (^FailsafeExecutor [pool policies] + (impl/->executor pool policies))) + +(defmacro execute + "Executes the given function with the provided Failsafe executor or policy. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/FailsafeExecutor.html + + Parameters: + - `executor-or-policy`: The FailsafeExecutor or Policies to use for execution. + - `context-binding`: the binding to use for the ExecutionContext context. + - `body`: the body of code to execute. + + Returns the result or throws an exception." + ([body] + `(execute [] context# ~body)) + ([executor-or-policy body] + `(execute ~executor-or-policy context# ~body)) + ([executor-or-policy context-binding body] + `(impl/execute-get ~executor-or-policy + (bound-fn [~(vary-meta context-binding assoc :tag ExecutionContext)] + ~body)))) + +(defmacro execute-async + "Executes the given async function with the provided Failsafe executor or policy. + + Docs: https://failsafe.dev/javadoc/core/dev/failsafe/FailsafeExecutor.html + + Parameters: + - `executor-or-policy`: The FailsafeExecutor or Policies to use for async execution. + - `context-binding`: the binding to use for the AsyncExecution context. + - `body`: the body of code to execute. + + Returns a future representing the result or exception." + ([body] + `(execute-async [] context# ~body)) + ([executor-or-policy body] + `(execute-async ~executor-or-policy context# ~body)) + ([executor-or-policy context-binding body] + `(impl/execute-get-async ~executor-or-policy + (bound-fn [~context-binding] + (async + (try + (impl/record-async-success ~context-binding (!event-listener + "Converts a Clojure function to a Failsafe EventListener." + ^EventListener [f] + (FailsafeEventListener. f)) + +(deftype FailsafeCheckedFunction [f] + CheckedFunction + (apply [_ args] + (f args))) + +(defn ->checked-function + "Converts a Clojure function to a Failsafe CheckedFunction." + ^CheckedFunction [f] + (FailsafeCheckedFunction. f)) + +(deftype FailsafeCheckedPredicate [f] + CheckedPredicate + (test [_ arg] + (boolean (f arg)))) + +(defn ->checked-predicate + "Converts a Clojure function to a Failsafe CheckedPredicate." + ^CheckedPredicate [f] + (FailsafeCheckedPredicate. f)) + +(deftype FailsafeAsyncRunnable [f] + AsyncRunnable + (run [_ execution] + (f execution))) + +(defn ->async-runnable + "Converts a Clojure function to a Failsafe AsyncRunnable." + ^AsyncRunnable [f] + (FailsafeAsyncRunnable. f)) + +(deftype FailsafeContextualSupplier [f] + ContextualSupplier + (get [_ context] + (f context))) + +(defn ->contextual-supplier + "Converts a Clojure function to a Failsafe ContextualSupplier." + ^ContextualSupplier [f] + (FailsafeContextualSupplier. f)) + +(defn get-pool + "Returns a Failsafe-compatible thread pool. If `pool` is a keyword, looks up the pool using `futurama.core/get-pool`. + Otherwise, returns the provided pool, a default thread pool, or falls back to `futurama.core/get-pool :io`." + ^ExecutorService [pool] + (if (keyword? pool) + (f/get-pool pool) + (or pool f/*thread-pool* (f/get-pool :io)))) + +(defn get-policy-list + "Returns a Failsafe-compatible sequence of policies." + ^List + [& policy-args] + (->> (for [policy (flatten policy-args) + :when (some? policy)] + (cond + (instance? Policy policy) + policy + + (satisfies? IPolicyBuilder policy) + (build-policy policy) + + :else (throw (ex-info "Invalid policy type" {:policy policy})))) + vec)) + +(defn record-async-success + "Records the result of an execution in the given ExecutionContext." + [^AsyncExecution context ^Object result] + (.recordResult context result)) + +(defn record-async-failure + "Records the error of an execution in the given ExecutionContext." + [^AsyncExecution context ^Throwable error] + (.recordException context error)) + +(defn ->executor + "Creates a FailsafeExecutor with the given thread pool and policies. + If no policies are provided, uses Failsafe.none() + If no pool is provided, uses a default thread pool." + (^FailsafeExecutor [] + (->executor nil nil)) + (^FailsafeExecutor [executor-or-policies] + (->executor nil executor-or-policies)) + (^FailsafeExecutor [pool executor-or-policies] + (let [pool (get-pool pool)] + (if (instance? FailsafeExecutor executor-or-policies) + (.with ^FailsafeExecutor executor-or-policies pool) + (let [policies (get-policy-list executor-or-policies)] + (if (empty? policies) + (.with (Failsafe/none) pool) + (.with (Failsafe/with policies) pool))))))) + +(defn execute-get + "Executes the given CheckedSupplier using the Failsafe executor." + [executor-or-policies execute-fn] + (.get (->executor executor-or-policies) + (->contextual-supplier execute-fn))) + +(defn execute-get-async + "Executes the given CheckedSupplier using the Failsafe executor." + [executor-or-policies execute-fn] + (.getAsyncExecution (->executor executor-or-policies) + (->async-runnable execute-fn))) diff --git a/test/failsage/core_test.clj b/test/failsage/core_test.clj index 5f53e6e..03fcaf6 100644 --- a/test/failsage/core_test.clj +++ b/test/failsage/core_test.clj @@ -1,5 +1,825 @@ (ns failsage.core-test - (:require [bond.james :as bond] - [clojure.test :refer [deftest is testing use-fixtures]] - [criterium.core :refer [quick-benchmark report-result - with-progress-reporting]])) + (:require + [clojure.test :refer [deftest is testing]] + [failsage.core :as fs] + [futurama.core :as f]) + (:import + [dev.failsafe + Bulkhead + BulkheadFullException + CircuitBreaker + CircuitBreakerOpenException + ExecutionContext + Fallback + Policy + RateLimitExceededException + RateLimiter + RetryPolicy + Timeout + TimeoutExceededException] + [java.lang ArithmeticException IllegalArgumentException])) + +;; Dynamic var for testing +(def ^:dynamic *test-var* nil) + +;; Bulkhead Tests + +(deftest test-bulkhead-creation + (testing "bulkhead creates a Bulkhead policy" + (let [policy (fs/bulkhead {:max-concurrency 5})] + (is (instance? Bulkhead policy)) + (is (instance? Policy policy))))) + +(deftest test-bulkhead-returns-builder + (testing "bulkhead returns builder when :build is false" + (let [builder (fs/bulkhead {:max-concurrency 5 :build false})] + (is (not (instance? Bulkhead builder)))))) + +(deftest test-bulkhead-with-max-wait-time + (testing "bulkhead with max wait time" + (let [policy (fs/bulkhead {:max-concurrency 2 :max-wait-time-ms 100})] + (is (instance? Bulkhead policy))))) + +(deftest test-bulkhead-success-callback + (testing "bulkhead calls on-success-fn when execution succeeds" + (let [success-called (atom false) + policy (fs/bulkhead {:max-concurrency 5 + :on-success-fn (fn [_] (reset! success-called true))}) + executor (fs/executor policy)] + (fs/execute executor "success") + (is @success-called)))) + +(deftest test-bulkhead-failure-callback + (testing "bulkhead calls on-failure-fn when execution fails" + (let [failure-called (atom false) + policy (fs/bulkhead {:max-concurrency 5 + :on-failure-fn (fn [_] (reset! failure-called true))}) + executor (fs/executor policy)] + (try + (fs/execute executor (throw (ex-info "fail" {}))) + (catch Exception _)) + (is @failure-called)))) + +(deftest test-bulkhead-concurrency-limit + (testing "bulkhead enforces concurrency limit" + (let [policy (fs/bulkhead {:max-concurrency 1 :max-wait-time-ms 0}) + executor (fs/executor policy) + latch (java.util.concurrent.CountDownLatch. 1) + _future1 (future (fs/execute executor (.await latch))) + _ (Thread/sleep 50)] + (is (thrown? BulkheadFullException + (fs/execute executor "should fail"))) + (.countDown latch)))) + +;; Circuit Breaker Tests + +(deftest test-circuit-breaker-creation + (testing "circuit-breaker creates a CircuitBreaker policy" + (let [policy (fs/circuit-breaker {:delay-ms 1000 :failure-threshold 3})] + (is (instance? CircuitBreaker policy)) + (is (instance? Policy policy))))) + +(deftest test-circuit-breaker-returns-builder + (testing "circuit-breaker returns builder when :build is false" + (let [builder (fs/circuit-breaker {:delay-ms 1000 :failure-threshold 3 :build false})] + (is (not (instance? CircuitBreaker builder)))))) + +(deftest test-circuit-breaker-with-failure-threshold + (testing "circuit-breaker opens after consecutive failures" + (let [policy (fs/circuit-breaker {:delay-ms 100 :failure-threshold 2}) + executor (fs/executor policy)] + ;; First failure + (try (fs/execute executor (throw (ex-info "fail1" {}))) (catch Exception _)) + ;; Second failure should open circuit + (try (fs/execute executor (throw (ex-info "fail2" {}))) (catch Exception _)) + ;; Third attempt should fail with CircuitBreakerOpenException + (is (thrown? CircuitBreakerOpenException + (fs/execute executor "should fail")))))) + +(deftest test-circuit-breaker-with-failure-rate-threshold + (testing "circuit-breaker with failure rate threshold" + (let [policy (fs/circuit-breaker {:delay-ms 1000 + :failure-rate-threshold 50 + :failure-execution-threshold 4 + :failure-thresholding-period-ms 10000})] + (is (instance? CircuitBreaker policy))))) + +(deftest test-circuit-breaker-with-failure-thresholding-capacity + (testing "circuit-breaker with failure thresholding capacity" + (let [policy (fs/circuit-breaker {:delay-ms 1000 + :failure-threshold 3 + :failure-thresholding-capacity 10})] + (is (instance? CircuitBreaker policy))))) + +(deftest test-circuit-breaker-with-success-threshold + (testing "circuit-breaker with success threshold" + (let [policy (fs/circuit-breaker {:delay-ms 100 + :failure-threshold 2 + :success-threshold 2})] + (is (instance? CircuitBreaker policy))))) + +(deftest test-circuit-breaker-state-callbacks + (testing "circuit-breaker calls state change callbacks" + (let [opened (atom false) + half-opened (atom false) + closed (atom false) + policy (fs/circuit-breaker {:delay-ms 50 + :failure-threshold 2 + :on-open-fn (fn [_] (reset! opened true)) + :on-half-open-fn (fn [_] (reset! half-opened true)) + :on-close-fn (fn [_] (reset! closed true))}) + executor (fs/executor policy)] + ;; Trigger failures to open circuit + (try (fs/execute executor (throw (ex-info "fail1" {}))) (catch Exception _)) + (try (fs/execute executor (throw (ex-info "fail2" {}))) (catch Exception _)) + (is @opened) + ;; Wait for half-open state + (Thread/sleep 100) + ;; Try execution to transition to half-open + (try (fs/execute executor "success") (catch Exception _)) + (Thread/sleep 50)))) + +(deftest test-circuit-breaker-handle-exception + (testing "circuit-breaker handles specific exceptions" + (let [policy (fs/circuit-breaker {:delay-ms 1000 + :failure-threshold 3 + :handle-exception IllegalArgumentException}) + executor (fs/executor policy)] + ;; ArithmeticException should not count as failure + (is (thrown? ArithmeticException + (fs/execute executor (throw (ArithmeticException. "not handled")))))))) + +(deftest test-circuit-breaker-handle-exception-fn + (testing "circuit-breaker with exception predicate" + (let [policy (fs/circuit-breaker {:delay-ms 1000 + :failure-threshold 3 + :handle-exception-fn (fn [e] (instance? IllegalArgumentException e))})] + (is (instance? CircuitBreaker policy))))) + +(deftest test-circuit-breaker-handle-result + (testing "circuit-breaker handles specific result as failure" + (let [policy (fs/circuit-breaker {:delay-ms 1000 + :failure-threshold 3 + :handle-result :error}) + executor (fs/executor policy)] + (is (= :error (fs/execute executor :error)))))) + +(deftest test-circuit-breaker-handle-result-fn + (testing "circuit-breaker with result predicate" + (let [policy (fs/circuit-breaker {:delay-ms 1000 + :failure-threshold 3 + :handle-result-fn (fn [r] (= r :error))})] + (is (instance? CircuitBreaker policy))))) + +;; Fallback Tests + +(deftest test-fallback-creation-with-static-result + (testing "fallback creates a Fallback policy with static result" + (let [policy (fs/fallback {:result "default"})] + (is (instance? Fallback policy)) + (is (instance? Policy policy))))) + +(deftest test-fallback-creation-with-function + (testing "fallback creates a Fallback policy with function" + (let [policy (fs/fallback {:result-fn (fn [_] "computed-default")})] + (is (instance? Fallback policy))))) + +(deftest test-fallback-returns-builder + (testing "fallback returns builder when :build is false" + (let [builder (fs/fallback {:result "default" :build false})] + (is (not (instance? Fallback builder)))))) + +(deftest test-fallback-returns-static-value + (testing "fallback returns static value on failure" + (let [policy (fs/fallback {:result "fallback-value"}) + executor (fs/executor policy) + result (fs/execute executor (throw (ex-info "fail" {})))] + (is (= "fallback-value" result))))) + +(deftest test-fallback-returns-computed-value + (testing "fallback returns computed value on failure" + (let [policy (fs/fallback {:result-fn (fn [_event] "computed")}) + executor (fs/executor policy) + result (fs/execute executor (throw (ex-info "fail" {})))] + (is (= "computed" result))))) + +(deftest test-fallback-with-handle-exception + (testing "fallback handles specific exceptions" + (let [policy (fs/fallback {:result "fallback" + :handle-exception IllegalArgumentException}) + executor (fs/executor policy)] + ;; ArithmeticException should not be handled + (is (thrown? ArithmeticException + (fs/execute executor (throw (ArithmeticException. "not handled")))))))) + +(deftest test-fallback-with-handle-result + (testing "fallback handles specific result" + (let [policy (fs/fallback {:result "fallback" + :handle-result :error}) + executor (fs/executor policy) + result (fs/execute executor :error)] + (is (= "fallback" result))))) + +(deftest test-fallback-success-callback + (testing "fallback calls on-success-fn" + (let [success-called (atom false) + policy (fs/fallback {:result "fallback" + :on-success-fn (fn [_] (reset! success-called true))}) + executor (fs/executor policy)] + (fs/execute executor "success") + (is @success-called)))) + +;; Rate Limiter Tests + +(deftest test-rate-limiter-creation-smooth + (testing "rate-limiter creates a smooth RateLimiter policy" + (let [policy (fs/rate-limiter {:max-executions 10 :period-ms 1000})] + (is (instance? RateLimiter policy)) + (is (instance? Policy policy))))) + +(deftest test-rate-limiter-creation-bursty + (testing "rate-limiter creates a bursty RateLimiter policy" + (let [policy (fs/rate-limiter {:max-executions 10 :period-ms 1000 :burst true})] + (is (instance? RateLimiter policy))))) + +(deftest test-rate-limiter-returns-builder + (testing "rate-limiter returns builder when :build is false" + (let [builder (fs/rate-limiter {:max-executions 10 :period-ms 1000 :build false})] + (is (not (instance? RateLimiter builder)))))) + +(deftest test-rate-limiter-with-max-wait-time + (testing "rate-limiter with max wait time" + (let [policy (fs/rate-limiter {:max-executions 10 + :period-ms 1000 + :max-wait-time-ms 100})] + (is (instance? RateLimiter policy))))) + +(deftest test-rate-limiter-enforces-limit + (testing "rate-limiter enforces execution rate" + (let [policy (fs/rate-limiter {:max-executions 2 + :period-ms 10000 + :burst true + :max-wait-time-ms 0}) + executor (fs/executor policy)] + ;; First two should succeed immediately (burst) + (is (= 1 (fs/execute executor 1))) + (is (= 2 (fs/execute executor 2))) + ;; Third should fail with rate limit exceeded + (is (thrown? RateLimitExceededException + (fs/execute executor 3)))))) + +(deftest test-rate-limiter-success-callback + (testing "rate-limiter calls on-success-fn" + (let [success-called (atom false) + policy (fs/rate-limiter {:max-executions 10 + :period-ms 1000 + :on-success-fn (fn [_] (reset! success-called true))}) + executor (fs/executor policy)] + (fs/execute executor "success") + (is @success-called)))) + +;; Retry Tests + +(deftest test-retry-creation + (testing "retry creates a RetryPolicy" + (let [policy (fs/retry {:max-retries 3})] + (is (instance? RetryPolicy policy)) + (is (instance? Policy policy))))) + +(deftest test-retry-returns-builder + (testing "retry returns builder when :build is false" + (let [builder (fs/retry {:max-retries 3 :build false})] + (is (not (instance? RetryPolicy builder)))))) + +(deftest test-retry-with-max-retries + (testing "retry retries up to max-retries" + (let [policy (fs/retry {:max-retries 2}) + executor (fs/executor policy) + counter (atom 0)] + (try + (fs/execute executor + (do + (swap! counter inc) + (when (<= @counter 2) + (throw (ex-info "retry" {}))) + "success")) + (catch Exception _)) + ;; Should have tried 3 times (initial + 2 retries) + (is (= 3 @counter))))) + +(deftest test-retry-with-delay + (testing "retry with fixed delay" + (let [policy (fs/retry {:max-retries 1 :delay-ms 50})] + (is (instance? RetryPolicy policy))))) + +(deftest test-retry-with-backoff + (testing "retry with exponential backoff" + (let [policy (fs/retry {:max-retries 3 + :backoff-delay-ms 10 + :backoff-max-delay-ms 100})] + (is (instance? RetryPolicy policy))))) + +(deftest test-retry-with-jitter + (testing "retry with jitter" + (let [policy (fs/retry {:max-retries 3 :jitter-ms 10})] + (is (instance? RetryPolicy policy))))) + +(deftest test-retry-with-max-duration + (testing "retry with max duration" + (let [policy (fs/retry {:max-retries -1 :max-duration-ms 100})] + (is (instance? RetryPolicy policy))))) + +(deftest test-retry-abort-on-exception + (testing "retry aborts on specific exception" + (let [policy (fs/retry {:max-retries 5 + :abort-on-exception IllegalArgumentException}) + executor (fs/executor policy) + counter (atom 0)] + (try + (fs/execute executor + (do + (swap! counter inc) + (throw (IllegalArgumentException. "abort")))) + (catch IllegalArgumentException _)) + ;; Should only try once, no retries + (is (= 1 @counter))))) + +(deftest test-retry-abort-if-result + (testing "retry aborts on specific result" + (let [policy (fs/retry {:max-retries 5 :abort-if-result :abort}) + executor (fs/executor policy) + counter (atom 0) + result (fs/execute executor + (do + (swap! counter inc) + :abort))] + (is (= :abort result)) + ;; Should only try once, no retries + (is (= 1 @counter))))) + +(deftest test-retry-handle-exception + (testing "retry handles specific exceptions" + (let [policy (fs/retry {:max-retries 2 + :handle-exception IllegalArgumentException}) + executor (fs/executor policy)] + ;; ArithmeticException should not trigger retry + (is (thrown? ArithmeticException + (fs/execute executor (throw (ArithmeticException. "not retried")))))))) + +(deftest test-retry-handle-result + (testing "retry retries on specific result" + (let [policy (fs/retry {:max-retries 2 :handle-result :retry}) + executor (fs/executor policy) + counter (atom 0) + result (fs/execute executor + (do + (swap! counter inc) + (if (< @counter 2) + :retry + :success)))] + (is (= :success result)) + (is (= 2 @counter))))) + +(deftest test-retry-callbacks + (testing "retry calls event callbacks" + (let [failed-attempt-count (atom 0) + retry-count (atom 0) + success-called (atom false) + policy (fs/retry {:max-retries 2 + :on-failed-attempt-fn (fn [_] (swap! failed-attempt-count inc)) + :on-retry-fn (fn [_] (swap! retry-count inc)) + :on-success-fn (fn [_] (reset! success-called true))}) + executor (fs/executor policy) + counter (atom 0)] + (fs/execute executor + (do + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success")) + (is (= 1 @failed-attempt-count)) + (is (= 1 @retry-count)) + (is @success-called)))) + +;; Timeout Tests + +(deftest test-timeout-creation + (testing "timeout creates a Timeout policy" + (let [policy (fs/timeout {:timeout-ms 1000})] + (is (instance? Timeout policy)) + (is (instance? Policy policy))))) + +(deftest test-timeout-returns-builder + (testing "timeout returns builder when :build is false" + (let [builder (fs/timeout {:timeout-ms 1000 :build false})] + (is (not (instance? Timeout builder)))))) + +(deftest test-timeout-with-interrupt + (testing "timeout with interrupt enabled" + (let [policy (fs/timeout {:timeout-ms 1000 :interrupt true})] + (is (instance? Timeout policy))))) + +(deftest test-timeout-enforces-limit + (testing "timeout enforces time limit" + (let [policy (fs/timeout {:timeout-ms 100}) + executor (fs/executor policy)] + (is (thrown? TimeoutExceededException + (fs/execute executor (Thread/sleep 200))))))) + +(deftest test-timeout-success-callback + (testing "timeout calls on-success-fn" + (let [success-called (atom false) + policy (fs/timeout {:timeout-ms 1000 + :on-success-fn (fn [_] (reset! success-called true))}) + executor (fs/executor policy)] + (fs/execute executor "success") + (is @success-called)))) + +;; Executor Tests + +(deftest test-executor-creation-no-policies + (testing "executor creates an executor with no policies" + (let [executor (fs/executor)] + (is (some? executor))))) + +(deftest test-executor-creation-single-policy + (testing "executor creates an executor with a single policy" + (let [policy (fs/retry {:max-retries 3}) + executor (fs/executor policy)] + (is (some? executor))))) + +(deftest test-executor-creation-multiple-policies + (testing "executor creates an executor with multiple policies" + (let [retry-policy (fs/retry {:max-retries 3}) + timeout-policy (fs/timeout {:timeout-ms 1000}) + executor (fs/executor [retry-policy timeout-policy])] + (is (some? executor))))) + +(deftest test-executor-with-custom-pool + (testing "executor uses custom thread pool" + (let [pool (f/get-pool :io) + executor (fs/executor pool nil)] + (is (some? executor))))) + +(deftest test-executor-with-pool-keyword + (testing "executor accepts pool keyword" + (let [executor (fs/executor :io nil)] + (is (some? executor))))) + +;; Execute Tests + +(deftest test-execute-basic + (testing "execute runs a simple expression" + (let [executor (fs/executor) + result (fs/execute executor (+ 1 2))] + (is (= 3 result))))) + +(deftest test-execute-with-side-effects + (testing "execute runs expressions with side effects" + (let [executor (fs/executor) + counter (atom 0) + result (fs/execute executor + (do + (swap! counter inc) + @counter))] + (is (= 1 result)) + (is (= 1 @counter))))) + +(deftest test-execute-propagates-exception + (testing "execute propagates exceptions" + (let [executor (fs/executor)] + (is (thrown-with-msg? Exception #"test error" + (fs/execute executor (throw (ex-info "test error" {})))))))) + +(deftest test-execute-with-retry-policy + (testing "execute retries on failure" + (let [policy (fs/retry {:max-retries 2}) + executor (fs/executor policy) + counter (atom 0)] + (fs/execute executor + (do + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success")) + (is (= 2 @counter))))) + +(deftest test-execute-with-fallback-policy + (testing "execute uses fallback on failure" + (let [policy (fs/fallback {:result "fallback-value"}) + executor (fs/executor policy) + result (fs/execute executor (throw (ex-info "fail" {})))] + (is (= "fallback-value" result))))) + +(deftest test-execute-with-multiple-policies + (testing "execute works with multiple policies" + (let [retry-policy (fs/retry {:max-retries 2}) + fallback-policy (fs/fallback {:result "fallback"}) + ;; Failsafe composes policies like functions: last-to-first + ;; So [fallback, retry] means fallback(retry(execution)) + executor (fs/executor [fallback-policy retry-policy]) + counter (atom 0) + result (fs/execute executor + (do + (swap! counter inc) + (throw (ex-info "fail" {}))))] + (is (= "fallback" result)) + ;; Should have tried 3 times (initial + 2 retries) + (is (= 3 @counter))))) + +(deftest test-execute-binds-dynamic-vars + (testing "execute preserves dynamic var bindings" + (let [executor (fs/executor)] + (binding [*test-var* "bound-value"] + (let [result (fs/execute executor *test-var*)] + (is (= "bound-value" result))))))) + +;; Execute with 1-arity (no executor) Tests + +(deftest test-execute-no-executor + (testing "execute with just body (no executor)" + (let [result (fs/execute (+ 1 2))] + (is (= 3 result))))) + +(deftest test-execute-no-executor-with-side-effects + (testing "execute with no executor handles side effects" + (let [counter (atom 0) + result (fs/execute (do + (swap! counter inc) + @counter))] + (is (= 1 result)) + (is (= 1 @counter))))) + +(deftest test-execute-no-executor-propagates-exception + (testing "execute with no executor propagates exceptions" + (is (thrown-with-msg? Exception #"test error" + (fs/execute (throw (ex-info "test error" {}))))))) + +;; Execute with 3-arity (executor, context-binding, body) Tests + +(deftest test-execute-with-context-binding + (testing "execute with context binding allows access to ExecutionContext" + (let [executor (fs/executor) + received-context (atom nil) + result (fs/execute executor ctx + (do + (reset! received-context ctx) + 42))] + (is (= 42 result)) + (is (some? @received-context)) + (is (instance? dev.failsafe.ExecutionContext @received-context))))) + +(deftest test-execute-context-has-attempt-count + (testing "execute ExecutionContext provides attempt count" + (let [policy (fs/retry {:max-retries 2}) + executor (fs/executor policy) + counter (atom 0) + attempt-counts (atom [])] + (fs/execute executor ctx + (do + (swap! attempt-counts conj (.getAttemptCount ^ExecutionContext ctx)) + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success")) + (is (= [0 1] @attempt-counts))))) + +(deftest test-execute-context-has-start-time + (testing "execute ExecutionContext provides start time" + (let [executor (fs/executor) + start-time (atom nil)] + (fs/execute executor ctx + (do + (reset! start-time (.getStartTime ^ExecutionContext ctx)) + 42)) + (is (some? @start-time))))) + +;; Execute with Policy (not Executor) Tests + +(deftest test-execute-with-policy-instead-of-executor + (testing "execute accepts a policy instead of executor" + (let [policy (fs/retry {:max-retries 2}) + counter (atom 0) + result (fs/execute policy + (do + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success"))] + (is (= "success" result)) + (is (= 2 @counter))))) + +(deftest test-execute-with-multiple-policies-directly + (testing "execute accepts a list of policies without creating executor" + (let [retry-policy (fs/retry {:max-retries 2}) + fallback-policy (fs/fallback {:result "fallback"}) + counter (atom 0) + result (fs/execute [fallback-policy retry-policy] + (do + (swap! counter inc) + (throw (ex-info "fail" {}))))] + (is (= "fallback" result)) + (is (= 3 @counter))))) + +(deftest test-execute-with-policy-and-context + (testing "execute with policy and context binding" + (let [policy (fs/retry {:max-retries 1}) + attempt-counts (atom [])] + (fs/execute policy ctx + (do + (swap! attempt-counts conj (.getAttemptCount ^ExecutionContext ctx)) + (when (= 1 (count @attempt-counts)) + (throw (ex-info "retry" {}))) + "success")) + (is (= [0 1] @attempt-counts))))) + +;; Execute Async Tests + +(deftest test-execute-async-basic + (testing "execute-async runs asynchronously" + (f/with-pool :io + (let [executor (fs/executor :io nil) + result (fs/execute-async executor + (f/async 42))] + (is (= 42 @result)))))) + +(deftest test-execute-async-with-retry + (testing "execute-async retries on failure" + (f/with-pool :io + (let [policy (fs/retry {:max-retries 2}) + executor (fs/executor :io policy) + counter (atom 0) + result (fs/execute-async executor + (f/async + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success"))] + (is (= "success" @result)) + (is (>= @counter 2)))))) + +(deftest test-execute-async-binds-dynamic-vars + (testing "execute-async preserves dynamic var bindings" + (f/with-pool :io + (let [executor (fs/executor :io nil)] + (binding [*test-var* "async-bound-value"] + (let [result (fs/execute-async executor + (f/async *test-var*))] + (is (= "async-bound-value" @result)))))))) + +;; Execute Async with 1-arity (no executor) Tests + +(deftest test-execute-async-no-executor + (testing "execute-async with just body (no executor)" + (f/with-pool :io + (let [result (fs/execute-async (f/async 42))] + (is (= 42 @result)))))) + +(deftest test-execute-async-no-executor-with-side-effects + (testing "execute-async with no executor handles side effects" + (f/with-pool :io + (let [counter (atom 0) + result (fs/execute-async (f/async + (swap! counter inc) + @counter))] + (is (= 1 @result)) + (is (= 1 @counter)))))) + +(deftest test-execute-async-no-executor-propagates-exception + (testing "execute-async with no executor propagates exceptions" + (f/with-pool :io + (let [future-result (fs/execute-async (f/async + (throw (ex-info "async error" {}))))] + ;; Exception is thrown when dereferencing the future + (is (thrown-with-msg? Exception #"async error" + @future-result)))))) + +;; Execute Async with 3-arity (executor, context-binding, body) Tests + +(deftest test-execute-async-with-context-binding + (testing "execute-async with context binding allows access to AsyncExecution" + (f/with-pool :io + (let [executor (fs/executor :io nil) + received-context (atom nil) + result (fs/execute-async executor ctx + (f/async + (reset! received-context ctx) + 42))] + (is (= 42 @result)) + (is (some? @received-context)) + (is (instance? dev.failsafe.AsyncExecution @received-context)))))) + +(deftest test-execute-async-context-has-attempt-count + (testing "execute-async AsyncExecution provides attempt count" + (f/with-pool :io + (let [policy (fs/retry {:max-retries 2}) + executor (fs/executor :io policy) + counter (atom 0) + attempt-counts (atom []) + result (fs/execute-async executor ctx + (f/async + (swap! attempt-counts conj (.getAttemptCount ^ExecutionContext ctx)) + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success"))] + (is (= "success" @result)) + (is (= [0 1] @attempt-counts)))))) + +(deftest test-execute-async-context-record-result + (testing "execute-async can manually record results using context" + (f/with-pool :io + (let [executor (fs/executor :io nil) + result (fs/execute-async executor ctx + (f/async + ;; The macro handles recording, but we can verify context methods exist + (.getAttemptCount ^ExecutionContext ctx)))] + (is (= 0 @result)))))) + +;; Execute Async with Policy (not Executor) Tests + +(deftest test-execute-async-with-policy-instead-of-executor + (testing "execute-async accepts a policy instead of executor" + (f/with-pool :io + (let [policy (fs/retry {:max-retries 2}) + counter (atom 0) + result (fs/execute-async policy + (f/async + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success"))] + (is (= "success" @result)) + (is (= 2 @counter)))))) + +(deftest test-execute-async-with-multiple-policies + (testing "execute-async accepts a list of policies" + (f/with-pool :io + (let [retry-policy (fs/retry {:max-retries 2}) + fallback-policy (fs/fallback {:result "fallback"}) + counter (atom 0) + result (fs/execute-async [fallback-policy retry-policy] + (f/async + (swap! counter inc) + (throw (ex-info "fail" {}))))] + (is (= "fallback" @result)) + (is (= 3 @counter)))))) + +(deftest test-execute-async-with-policy-and-context + (testing "execute-async with policy and context binding" + (f/with-pool :io + (let [policy (fs/retry {:max-retries 1}) + attempt-counts (atom []) + result (fs/execute-async policy ctx + (f/async + (swap! attempt-counts conj (.getAttemptCount ^ExecutionContext ctx)) + (when (= 1 (count @attempt-counts)) + (throw (ex-info "retry" {}))) + "success"))] + (is (= "success" @result)) + (is (= [0 1] @attempt-counts)))))) + +;; Integration Tests + +(deftest test-integration-circuit-breaker-with-fallback + (testing "circuit breaker with fallback provides graceful degradation" + (let [cb-policy (fs/circuit-breaker {:delay-ms 1000 :failure-threshold 2}) + fb-policy (fs/fallback {:result "fallback"}) + ;; Failsafe composes: last-to-first, so [fallback, circuit-breaker] means + ;; fallback wraps circuit-breaker wraps execution + ;; This allows circuit breaker to count failures before fallback catches them + executor (fs/executor [fb-policy cb-policy])] + ;; First two failures open the circuit + (is (= "fallback" (fs/execute executor (throw (ex-info "fail1" {}))))) + (is (= "fallback" (fs/execute executor (throw (ex-info "fail2" {}))))) + ;; Circuit is now open, but fallback still provides a result + (is (= "fallback" (fs/execute executor "should not execute")))))) + +(deftest test-integration-retry-with-timeout + (testing "retry with timeout limits total execution time" + (let [retry-policy (fs/retry {:max-retries 5 :delay-ms 100}) + timeout-policy (fs/timeout {:timeout-ms 250}) + ;; Failsafe composes: last-to-first, so [timeout, retry] means + ;; timeout wraps retry wraps execution + ;; This allows timeout to limit the TOTAL time including all retries + executor (fs/executor [timeout-policy retry-policy])] + (is (thrown? TimeoutExceededException + (fs/execute executor + (do + (Thread/sleep 50) + (throw (ex-info "retry" {}))))))))) + +(deftest test-integration-bulkhead-with-retry + (testing "bulkhead with retry policy" + (let [bulkhead-policy (fs/bulkhead {:max-concurrency 5}) + retry-policy (fs/retry {:max-retries 2}) + executor (fs/executor [bulkhead-policy retry-policy]) + counter (atom 0) + result (fs/execute executor + (do + (swap! counter inc) + (when (< @counter 2) + (throw (ex-info "retry" {}))) + "success"))] + (is (= "success" result)) + (is (= 2 @counter))))) diff --git a/test/failsage/impl_test.clj b/test/failsage/impl_test.clj new file mode 100644 index 0000000..ec0b889 --- /dev/null +++ b/test/failsage/impl_test.clj @@ -0,0 +1,450 @@ +(ns failsage.impl-test + (:require + [clojure.test :refer [deftest is testing]] + [failsage.impl :as impl] + [futurama.core :as f]) + (:import + [dev.failsafe + Bulkhead + CircuitBreaker + ExecutionContext + Failsafe + Fallback + Policy + RateLimiter + RetryPolicy + Timeout] + [java.time Duration] + [java.util.concurrent ExecutorService])) + +;; IPolicyBuilder Protocol Tests + +(deftest test-build-policy-bulkhead-builder + (testing "build-policy builds a Bulkhead from BulkheadBuilder" + (let [builder (-> (Bulkhead/builder 5) + (.withMaxWaitTime (Duration/ofMillis 100))) + policy (impl/build-policy builder)] + (is (instance? Bulkhead policy)) + (is (instance? Policy policy))))) + +(deftest test-build-policy-circuit-breaker-builder + (testing "build-policy builds a CircuitBreaker from CircuitBreakerBuilder" + (let [builder (-> (CircuitBreaker/builder) + (.withDelay (Duration/ofMillis 100))) + policy (impl/build-policy builder)] + (is (instance? CircuitBreaker policy)) + (is (instance? Policy policy))))) + +(deftest test-build-policy-fallback-builder + (testing "build-policy builds a Fallback from FallbackBuilder" + (let [builder (Fallback/builder "default-value") + policy (impl/build-policy builder)] + (is (instance? Fallback policy)) + (is (instance? Policy policy))))) + +(deftest test-build-policy-rate-limiter-builder + (testing "build-policy builds a RateLimiter from RateLimiterBuilder" + (let [builder (RateLimiter/smoothBuilder 10 (Duration/ofSeconds 1)) + policy (impl/build-policy builder)] + (is (instance? RateLimiter policy)) + (is (instance? Policy policy))))) + +(deftest test-build-policy-retry-policy-builder + (testing "build-policy builds a RetryPolicy from RetryPolicyBuilder" + (let [builder (-> (RetryPolicy/builder) + (.withMaxRetries 3)) + policy (impl/build-policy builder)] + (is (instance? RetryPolicy policy)) + (is (instance? Policy policy))))) + +(deftest test-build-policy-timeout-builder + (testing "build-policy builds a Timeout from TimeoutBuilder" + (let [builder (Timeout/builder (Duration/ofSeconds 5)) + policy (impl/build-policy builder)] + (is (instance? Timeout policy)) + (is (instance? Policy policy))))) + +;; EventListener Converter Tests + +(deftest test-event-listener-creation + (testing "->event-listener creates a valid EventListener" + (let [called (atom false) + listener (impl/->event-listener (fn [_] (reset! called true)))] + (is (some? listener)) + (.accept listener nil) + (is @called)))) + +(deftest test-event-listener-receives-event + (testing "EventListener receives and processes events" + (let [event-data (atom nil) + listener (impl/->event-listener (fn [e] (reset! event-data e))) + test-event {:type :test :data "test-data"}] + (.accept listener test-event) + (is (= test-event @event-data))))) + +(deftest test-event-listener-accept-unchecked + (testing "EventListener acceptUnchecked works correctly" + (let [called (atom false) + listener (impl/->event-listener (fn [_] (reset! called true)))] + (.acceptUnchecked listener nil) + (is @called)))) + +;; ContextualSupplier Converter Tests + +(deftest test-contextual-supplier-creation + (testing "->contextual-supplier creates a valid ContextualSupplier" + (let [supplier (impl/->contextual-supplier (fn [_] + 42))] + (is (some? supplier)) + (is (= 42 (.get supplier nil)))))) + +(deftest test-contextual-supplier-with-exception + (testing "ContextualSupplier propagates exceptions" + (let [supplier (impl/->contextual-supplier (fn [_] (throw (ex-info "test error" {}))))] + (is (thrown? Exception (.get supplier nil)))))) + +(deftest test-contextual-supplier-with-side-effects + (testing "ContextualSupplier executes side effects" + (let [counter (atom 0) + supplier (impl/->contextual-supplier (fn [_] + (swap! counter inc) + @counter))] + (is (= 1 (.get supplier nil))) + (is (= 2 (.get supplier nil))) + (is (= 2 @counter))))) + +;; CheckedFunction Converter Tests + +(deftest test-checked-function-creation + (testing "->checked-function creates a valid CheckedFunction" + (let [func (impl/->checked-function (fn [x] (* x 2)))] + (is (some? func)) + (is (= 10 (.apply func 5)))))) + +(deftest test-checked-function-with-exception + (testing "CheckedFunction propagates exceptions" + (let [func (impl/->checked-function (fn [_] (throw (ex-info "test error" {}))))] + (is (thrown? Exception (.apply func nil)))))) + +(deftest test-checked-function-with-complex-input + (testing "CheckedFunction handles complex input" + (let [func (impl/->checked-function (fn [m] (get m :value)))] + (is (= 42 (.apply func {:value 42})))))) + +;; CheckedPredicate Converter Tests + +(deftest test-checked-predicate-creation + (testing "->checked-predicate creates a valid CheckedPredicate" + (let [pred (impl/->checked-predicate (fn [x] (> x 5)))] + (is (some? pred)) + (is (true? (.test pred 10))) + (is (false? (.test pred 3)))))) + +(deftest test-checked-predicate-with-nil + (testing "CheckedPredicate handles nil correctly" + (let [pred (impl/->checked-predicate (fn [x] (some? x)))] + (is (false? (.test pred nil))) + (is (true? (.test pred "value")))))) + +(deftest test-checked-predicate-boolean-coercion + (testing "CheckedPredicate coerces non-boolean results to boolean" + (let [pred (impl/->checked-predicate (fn [x] x))] + (is (true? (.test pred "truthy"))) + (is (false? (.test pred nil))) + (is (false? (.test pred false)))))) + +(deftest test-checked-predicate-with-exception + (testing "CheckedPredicate propagates exceptions" + (let [pred (impl/->checked-predicate (fn [_] (throw (ex-info "test error" {}))))] + (is (thrown? Exception (.test pred nil)))))) + +;; AsyncRunnable Converter Tests + +(deftest test-async-runnable-creation + (testing "->async-runnable creates a valid AsyncRunnable" + (let [called (atom false) + runnable (impl/->async-runnable (fn [_] (reset! called true)))] + (is (some? runnable)) + (.run runnable nil) + (is @called)))) + +(deftest test-async-runnable-receives-execution + (testing "AsyncRunnable receives execution context" + (let [called (atom false) + received-arg (atom nil) + runnable (impl/->async-runnable (fn [exec] + (reset! called true) + (reset! received-arg exec)))] + (.run runnable nil) + (is @called) + (is (nil? @received-arg))))) + +;; Thread Pool Tests + +(deftest test-get-pool-with-keyword + (testing "get-pool returns a pool for keyword" + (let [pool (impl/get-pool :io)] + (is (instance? ExecutorService pool))))) + +(deftest test-get-pool-with-executor-service + (testing "get-pool returns the provided ExecutorService" + (let [custom-pool (f/get-pool :io) + result (impl/get-pool custom-pool)] + (is (identical? custom-pool result))))) + +(deftest test-get-pool-with-nil + (testing "get-pool returns default pool when nil is provided" + (let [pool (impl/get-pool nil)] + (is (instance? ExecutorService pool))))) + +(deftest test-get-pool-falls-back-to-io + (testing "get-pool falls back to :io pool when no pool is bound" + (binding [f/*thread-pool* nil] + (let [pool (impl/get-pool nil)] + (is (instance? ExecutorService pool)))))) + +;; Policy List Tests + +(deftest test-get-policy-list-with-single-policy + (testing "get-policy-list handles a single policy" + (let [policy (.build (RetryPolicy/builder)) + result (impl/get-policy-list policy)] + (is (= 1 (count result))) + (is (instance? Policy (first result)))))) + +(deftest test-get-policy-list-with-multiple-policies + (testing "get-policy-list handles multiple policies" + (let [policy1 (.build (RetryPolicy/builder)) + policy2 (.build (Timeout/builder (Duration/ofSeconds 5))) + result (impl/get-policy-list policy1 policy2)] + (is (= 2 (count result))) + (is (every? #(instance? Policy %) result))))) + +(deftest test-get-policy-list-with-builder + (testing "get-policy-list builds policies from builders" + (let [builder (RetryPolicy/builder) + result (impl/get-policy-list builder)] + (is (= 1 (count result))) + (is (instance? RetryPolicy (first result)))))) + +(deftest test-get-policy-list-with-mixed-types + (testing "get-policy-list handles mixed policies and builders" + (let [policy (.build (RetryPolicy/builder)) + builder (Timeout/builder (Duration/ofSeconds 5)) + result (impl/get-policy-list policy builder)] + (is (= 2 (count result))) + (is (instance? RetryPolicy (first result))) + (is (instance? Timeout (second result)))))) + +(deftest test-get-policy-list-filters-nil + (testing "get-policy-list filters out nil values" + (let [policy (.build (RetryPolicy/builder)) + result (impl/get-policy-list policy nil nil)] + (is (= 1 (count result)))))) + +(deftest test-get-policy-list-flattens-nested + (testing "get-policy-list flattens nested collections" + (let [policy1 (.build (RetryPolicy/builder)) + policy2 (.build (Timeout/builder (Duration/ofSeconds 5))) + result (impl/get-policy-list [policy1 policy2])] + (is (= 2 (count result)))))) + +(deftest test-get-policy-list-empty + (testing "get-policy-list handles empty input" + (let [result (impl/get-policy-list)] + (is (empty? result))))) + +(deftest test-get-policy-list-invalid-type + (testing "get-policy-list throws on invalid policy type" + (is (thrown-with-msg? Exception #"Invalid policy type" + (impl/get-policy-list "not-a-policy"))))) + +;; Execute Tests + +(deftest test-execute-get-basic + (testing "execute-get executes a simple function" + (let [executor (Failsafe/none) + result (impl/execute-get executor (fn [_] 42))] + (is (= 42 result))))) + +(deftest test-execute-get-with-side-effects + (testing "execute-get executes functions with side effects" + (let [executor (Failsafe/none) + counter (atom 0) + result (impl/execute-get executor (fn [_] + (swap! counter inc) + @counter))] + (is (= 1 result)) + (is (= 1 @counter))))) + +(deftest test-execute-get-propagates-exception + (testing "execute-get propagates exceptions" + (let [executor (Failsafe/none)] + (is (thrown-with-msg? Exception #"test error" + (impl/execute-get executor + (fn [_] + (throw (ex-info "test error" {}))))))))) + +(deftest test-execute-get-with-retry + (testing "execute-get works with retry policy" + (let [retry-policy (.build (-> (RetryPolicy/builder) + (.withMaxRetries 2))) + executor (Failsafe/with [retry-policy]) + counter (atom 0) + result (impl/execute-get executor + (fn [_] + (swap! counter inc) + (if (< @counter 2) + (throw (ex-info "retry" {})) + "success")))] + (is (= "success" result)) + (is (= 2 @counter))))) + +;; ->executor Tests + +(deftest test-executor-creation-no-args + (testing "->executor creates an executor with no arguments" + (let [executor (impl/->executor)] + (is (some? executor)) + (is (instance? dev.failsafe.FailsafeExecutor executor))))) + +(deftest test-executor-creation-with-policies + (testing "->executor creates an executor with policies" + (let [retry-policy (.build (RetryPolicy/builder)) + executor (impl/->executor [retry-policy])] + (is (some? executor)) + (is (instance? dev.failsafe.FailsafeExecutor executor))))) + +(deftest test-executor-creation-with-pool-and-policies + (testing "->executor creates an executor with pool and policies" + (let [retry-policy (.build (RetryPolicy/builder)) + executor (impl/->executor :io [retry-policy])] + (is (some? executor)) + (is (instance? dev.failsafe.FailsafeExecutor executor))))) + +(deftest test-executor-creation-from-executor + (testing "->executor accepts a FailsafeExecutor and returns a new one with pool" + (let [base-executor (Failsafe/none) + executor (impl/->executor :io base-executor)] + (is (some? executor)) + (is (instance? dev.failsafe.FailsafeExecutor executor))))) + +(deftest test-executor-creation-with-empty-policies + (testing "->executor with empty policies uses Failsafe.none" + (let [executor (impl/->executor [])] + (is (some? executor)) + (is (instance? dev.failsafe.FailsafeExecutor executor))))) + +(deftest test-executor-creation-with-single-policy + (testing "->executor with single policy (not in collection)" + (let [retry-policy (.build (RetryPolicy/builder)) + executor (impl/->executor retry-policy)] + (is (some? executor)) + (is (instance? dev.failsafe.FailsafeExecutor executor))))) + +;; record-async-success and record-async-failure Tests + +(deftest test-record-async-success + (testing "record-async-success records result on AsyncExecution" + (let [recorded-result (atom nil) + mock-execution (reify dev.failsafe.AsyncExecution + (recordResult [_ result] + (reset! recorded-result result)))] + (impl/record-async-success mock-execution "success-value") + (is (= "success-value" @recorded-result))))) + +(deftest test-record-async-failure + (testing "record-async-failure records exception on AsyncExecution" + (let [recorded-exception (atom nil) + mock-execution (reify dev.failsafe.AsyncExecution + (recordException [_ exception] + (reset! recorded-exception exception))) + test-error (ex-info "test error" {})] + (impl/record-async-failure mock-execution test-error) + (is (= test-error @recorded-exception))))) + +;; ContextualSupplier with ExecutionContext Tests + +(deftest test-contextual-supplier-receives-context + (testing "ContextualSupplier receives ExecutionContext" + (let [received-context (atom nil) + supplier (impl/->contextual-supplier (fn [ctx] + (reset! received-context ctx) + 42)) + mock-context (reify dev.failsafe.ExecutionContext)] + (is (= 42 (.get supplier mock-context))) + (is (some? @received-context))))) + +(deftest test-contextual-supplier-with-nil-context + (testing "ContextualSupplier handles nil context" + (let [supplier (impl/->contextual-supplier (fn [ctx] + (if (nil? ctx) + "nil-context" + "has-context")))] + (is (= "nil-context" (.get supplier nil)))))) + +;; Execute Tests with Context + +(deftest test-execute-get-with-context + (testing "execute-get passes ExecutionContext to function" + (let [executor (Failsafe/none) + received-context (atom nil) + result (impl/execute-get executor (fn [ctx] + (reset! received-context ctx) + 42))] + (is (= 42 result)) + (is (some? @received-context)) + (is (instance? dev.failsafe.ExecutionContext @received-context))))) + +(deftest test-execute-get-context-has-attempt-count + (testing "execute-get ExecutionContext has attempt information" + (let [retry-policy (.build (-> (RetryPolicy/builder) + (.withMaxRetries 2))) + executor (Failsafe/with [retry-policy]) + counter (atom 0) + attempt-counts (atom [])] + (impl/execute-get executor + (fn [ctx] + (swap! attempt-counts conj (.getAttemptCount ^ExecutionContext ctx)) + (swap! counter inc) + (if (< @counter 2) + (throw (ex-info "retry" {})) + "success"))) + (is (= [0 1] @attempt-counts))))) + +;; Execute Async Tests + +(deftest test-execute-get-async-basic + (testing "execute-get-async executes an async function" + (let [executor (.with (Failsafe/none) ^ExecutorService (f/get-pool :io)) + result (impl/execute-get-async executor + (fn [ctx] + (f/async + (impl/record-async-success ctx 42))))] + (is (= 42 @result))))) + +(deftest test-execute-get-async-with-exception + (testing "execute-get-async handles exceptions" + (let [executor (.with (Failsafe/none) ^ExecutorService (f/get-pool :io)) + result (impl/execute-get-async executor + (fn [ctx] + (f/async + (try + (throw (ex-info "async error" {})) + (catch Throwable t + (impl/record-async-failure ctx t))))))] + (is (thrown-with-msg? Exception #"async error" + @result))))) + +(deftest test-execute-get-async-with-context + (testing "execute-get-async passes AsyncExecution context to function" + (let [executor (.with (Failsafe/none) ^ExecutorService (f/get-pool :io)) + received-context (atom nil) + result (impl/execute-get-async executor + (fn [ctx] + (reset! received-context ctx) + (f/async + (impl/record-async-success ctx 42))))] + (is (= 42 @result)) + (is (some? @received-context)) + (is (instance? dev.failsafe.AsyncExecution @received-context))))) diff --git a/tests.edn b/tests.edn index c14236b..79e0421 100644 --- a/tests.edn +++ b/tests.edn @@ -1,10 +1,12 @@ -#kaocha/v1 {:capture-output? true +#kaocha/v1 {:capture-output? false :kaocha/fail-fast? false :plugins [:kaocha.plugin/profiling + :kaocha.plugin/gc-profiling :kaocha.plugin/print-invocations - :preloads] - :tests [{:id :unit - :kaocha/fail-fast? false + :kaocha.plugin/junit-xml] + :kaocha.plugin.junit-xml/target-file "target/junit.xml" + :kaocha.plugin.junit-xml/add-location-metadata? true + :tests [{:id :unit :kaocha/source-paths ["src"] :kaocha/test-paths ["test"] :ns-patterns [".*"]}]}