diff --git a/.gitignore b/.gitignore index bbdab3c..fbc89d9 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,11 @@ application-secrets.yaml *.jks *.keystore secrets/ + +# --- Local dev tooling, not part of the service --- +# The Flutter desktop console: a developer client for this API, with its own +# toolchain and build output. Useful locally, but it is not the service and +# would drag a second SDK into anyone who clones this repo. +payment-console/ +# Throwaway experiments — provider mocks, load-test scripts, captured payloads. +scratchpad/ diff --git a/src/main/java/com/test/payment/client/AirtelClient.java b/src/main/java/com/test/payment/client/AirtelClient.java index 2ae9b2a..402059e 100644 --- a/src/main/java/com/test/payment/client/AirtelClient.java +++ b/src/main/java/com/test/payment/client/AirtelClient.java @@ -2,7 +2,6 @@ package com.test.payment.client; import com.test.payment.dto.AirtelPaymentRequestDto; import com.test.payment.dto.AirtelResponseDto; -import com.test.payment.exceptions.ProviderBusyException; import com.test.payment.service.AirtelTokenService; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import io.github.resilience4j.ratelimiter.annotation.RateLimiter; @@ -15,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; -import java.time.Duration; @Component @RequiredArgsConstructor @@ -43,11 +41,7 @@ public class AirtelClient { .onStatus(HttpStatusCode::isError, resp -> ProviderHttpErrors.map(resp, "AIRTEL", tokenService.evictToken())) .bodyToMono(AirtelResponseDto.class)) - .retryWhen(reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(3)) - .maxBackoff(Duration.ofSeconds(30)) - .jitter(0.5) - .filter(ProviderBusyException.class::isInstance) - .onRetryExhaustedThrow((spec, signal) -> signal.failure())); + .retryWhen(ProviderBackoff.whenBusy("AIRTEL")); } @CircuitBreaker(name = "airtelCircuitBreaker") diff --git a/src/main/java/com/test/payment/client/MpesaClient.java b/src/main/java/com/test/payment/client/MpesaClient.java index 933a145..2b59894 100644 --- a/src/main/java/com/test/payment/client/MpesaClient.java +++ b/src/main/java/com/test/payment/client/MpesaClient.java @@ -4,7 +4,6 @@ import com.test.payment.dto.MpesaRequestDto; import com.test.payment.dto.MpesaResponse; import com.test.payment.dto.StkQueryRequestDto; import com.test.payment.dto.StkQueryResponseDto; -import com.test.payment.exceptions.ProviderBusyException; import com.test.payment.exceptions.ProviderProcessingException; import com.test.payment.service.MpesaTokenService; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; @@ -18,15 +17,14 @@ import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; -import java.time.Duration; /** * All outbound Safaricom calls live here so the Resilience4j annotations are applied * through Spring AOP (they would be silently skipped on self-invocation inside a service). * - * The Resilience4j @Retry handles network-level failures; the reactive backoff retry - * below handles ProviderBusyException (rate limiting / "system busy"). Both coexist - * intentionally. + * The Resilience4j @Retry handles network-level failures; ProviderBackoff.whenBusy + * handles ProviderBusyException (rate limiting / "system busy") with exponential + * backoff. Both coexist intentionally. */ @Component @RequiredArgsConstructor @@ -48,11 +46,7 @@ public class MpesaClient { .retrieve() .onStatus(HttpStatusCode::isError, resp -> mapError(resp, false)) .bodyToMono(MpesaResponse.class)) - .retryWhen(reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(3)) - .maxBackoff(Duration.ofSeconds(30)) - .jitter(0.5) - .filter(ProviderBusyException.class::isInstance) - .onRetryExhaustedThrow((spec, signal) -> signal.failure())); + .retryWhen(ProviderBackoff.whenBusy("MPESA")); } @CircuitBreaker(name = "mpesaCircuitBreaker") diff --git a/src/main/java/com/test/payment/client/MtnClient.java b/src/main/java/com/test/payment/client/MtnClient.java index 117186f..4fb7159 100644 --- a/src/main/java/com/test/payment/client/MtnClient.java +++ b/src/main/java/com/test/payment/client/MtnClient.java @@ -2,7 +2,6 @@ package com.test.payment.client; import com.test.payment.dto.MtnPayRequestDto; import com.test.payment.dto.MtnStatusResponseDto; -import com.test.payment.exceptions.ProviderBusyException; import com.test.payment.service.MtnTokenService; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import io.github.resilience4j.ratelimiter.annotation.RateLimiter; @@ -16,7 +15,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; -import java.time.Duration; @Component @RequiredArgsConstructor @@ -52,11 +50,7 @@ public class MtnClient { resp -> ProviderHttpErrors.map(resp, "MTN", tokenService.evictToken())) .toBodilessEntity() .then()) - .retryWhen(reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(3)) - .maxBackoff(Duration.ofSeconds(30)) - .jitter(0.5) - .filter(ProviderBusyException.class::isInstance) - .onRetryExhaustedThrow((spec, signal) -> signal.failure())); + .retryWhen(ProviderBackoff.whenBusy("MTN")); } @CircuitBreaker(name = "mtnCircuitBreaker") diff --git a/src/main/java/com/test/payment/client/ProviderBackoff.java b/src/main/java/com/test/payment/client/ProviderBackoff.java new file mode 100644 index 0000000..1399115 --- /dev/null +++ b/src/main/java/com/test/payment/client/ProviderBackoff.java @@ -0,0 +1,65 @@ +package com.test.payment.client; + +import com.test.payment.exceptions.ProviderBusyException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; + +import java.time.Duration; + +/** + * The reactive backoff every provider client uses for {@link ProviderBusyException}. + * + *
This is a second, separate retry from the Resilience4j + * {@code @Retry} annotation, and the two coexist on purpose: {@code @Retry} handles + * network-level failures on a fixed 2s interval, while this one handles a provider + * that answered perfectly well to say "slow down" (429, or a busy body). Backing off + * a rate limit at a fixed interval just gets refused again, so this one is + * exponential with jitter — spreading concurrent callers out instead of having them + * all come back in lockstep. + * + *
It logs through the same {@code com.test.payment.resilience} logger as the + * Resilience4j event consumers, so one level setting covers every wait in the stack. + */ +public final class ProviderBackoff { + + private static final Logger log = LoggerFactory.getLogger("com.test.payment.resilience"); + + private static final int MAX_ATTEMPTS = 3; + private static final Duration FIRST_BACKOFF = Duration.ofSeconds(3); + private static final Duration MAX_BACKOFF = Duration.ofSeconds(30); + private static final double JITTER = 0.5; + + private ProviderBackoff() { + } + + /** + * @param provider operator name, for the log line — the spec is otherwise identical + * for every provider + */ + public static RetryBackoffSpec whenBusy(String provider) { + return Retry.backoff(MAX_ATTEMPTS, FIRST_BACKOFF) + .maxBackoff(MAX_BACKOFF) + .jitter(JITTER) + .filter(ProviderBusyException.class::isInstance) + // Reactor computes the delay internally and does not expose it to the + // callback, so the line reports the attempt rather than inventing a + // number that might not match what it actually sleeps. + .doBeforeRetry(signal -> log.warn( + "[{}] provider busy — backing off before retry {} of {} (exponential from {}s, " + + "capped at {}s, {}% jitter): {}", + provider, + signal.totalRetries() + 1, + MAX_ATTEMPTS, + FIRST_BACKOFF.toSeconds(), + MAX_BACKOFF.toSeconds(), + (int) (JITTER * 100), + signal.failure().getMessage())) + .onRetryExhaustedThrow((spec, signal) -> { + log.error("[{}] provider still busy after {} backoff attempt(s) — giving up", + provider, signal.totalRetries()); + return signal.failure(); + }); + } +} diff --git a/src/main/java/com/test/payment/configurations/ResilienceEventLogging.java b/src/main/java/com/test/payment/configurations/ResilienceEventLogging.java new file mode 100644 index 0000000..9cd69b3 --- /dev/null +++ b/src/main/java/com/test/payment/configurations/ResilienceEventLogging.java @@ -0,0 +1,237 @@ +package com.test.payment.configurations; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.core.registry.EntryAddedEvent; +import io.github.resilience4j.core.registry.EntryRemovedEvent; +import io.github.resilience4j.core.registry.EntryReplacedEvent; +import io.github.resilience4j.core.registry.RegistryEventConsumer; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.retry.Retry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Makes the resilience layer audible. + * + *
Resilience4j publishes events but ships no logging of its own, so + * without these consumers a retry, an opening circuit breaker or a refused rate + * limiter permit is completely invisible: the call just takes longer, or fails + * with an exception that says nothing about which guard produced it. + * + *
Subscription happens at the registry level rather than per + * instance at startup, because Resilience4j creates instances lazily on first use + * — an instance that has never been called does not exist yet, so iterating the + * registry during {@code @PostConstruct} would find nothing to subscribe to. + * + *
Levels are chosen by operational meaning, not by event type: anything that + * says the provider is degrading is WARN, anything that means calls are actively + * being rejected is ERROR, recovery is INFO, and the per-call chatter is DEBUG. + * Everything logs through one logger name so it can be turned up or down in one + * place — {@code logging.level.com.test.payment.resilience}. + */ +@Configuration +public class ResilienceEventLogging { + + private static final Logger log = LoggerFactory.getLogger("com.test.payment.resilience"); + + /** + * Retry: every attempt, with the wait before it. + * + *
This is the {@code @Retry} annotation's retry (network-level failures and
+ * ProviderTransientException, 3 attempts 2s apart). It is not the
+ * reactive backoff in the clients that handles ProviderBusyException — that one
+ * logs itself, from {@code doBeforeRetry}.
+ */
+ @Bean
+ public RegistryEventConsumer {@code getAvailablePermissions()} goes negative under load: the
+ * AtomicRateLimiter hands out permits reserved against upcoming refresh windows
+ * rather than parking the caller, so -9 means nine calls are already spoken for
+ * before the next window opens. {@code getNumberOfWaitingThreads()} stays 0 for
+ * the same reason, which is why it is not reported — it would read as "nothing is
+ * queued" at the exact moment the limiter is saturated.
+ */
+ private static String permits(RateLimiter limiter) {
+ int available = limiter.getMetrics().getAvailablePermissions();
+ return available >= 0
+ ? "%d left this window".formatted(available)
+ : "window exhausted, %d call(s) reserved against upcoming windows".formatted(-available);
+ }
+
+ /** Exception summary that keeps the type — the message alone often says nothing. */
+ private static String describe(Throwable t) {
+ if (t == null) {
+ return "none";
+ }
+ return t.getMessage() == null
+ ? t.getClass().getSimpleName()
+ : "%s: %s".formatted(t.getClass().getSimpleName(), t.getMessage());
+ }
+
+ /**
+ * Only entry-added matters here — instances are never removed or replaced at
+ * runtime in this app. Defaulting the other two keeps each consumer to the one
+ * method that does something.
+ */
+ private abstract static class Consumer Exists so a client does not hardcode a poll rate: the poll interval has to
+ * fit inside the provider's call budget, and only the server knows what that is.
+ */
+ @GetMapping("/config")
+ public Mono These live on the server because the server is what knows the provider's rate
+ * limits. A client that picks its own poll rate is guessing at a budget it cannot
+ * see: polling a PENDING payment is a live provider call, so a client polling too
+ * eagerly spends the same quota the payments themselves need, and the first symptom
+ * is a 429 on someone else's payment.
+ *
+ * Advisory, not enforced — nothing stops a client ignoring these. The rate limiter
+ * is what actually protects the provider.
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class ClientConfigDto {
+
+ /** How often to re-check a payment that is still PENDING. Costs a provider call. */
+ private long statusPollIntervalSeconds;
+
+ /** How often to refresh the transaction list. Database only, no provider call. */
+ private long transactionRefreshIntervalSeconds;
+
+ /** Calls per minute the provider allows, so a client can show its budget. */
+ private int providerCallsPerMinute;
+}
diff --git a/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java b/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java
index c8548d9..9f94dd9 100644
--- a/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java
+++ b/src/main/java/com/test/payment/jobs/PaymentReconciliationJob.java
@@ -50,6 +50,8 @@ public class PaymentReconciliationJob {
LocalDateTime now = LocalDateTime.now();
Duration pendingAge = duration("payments.reconciliation.pending-age", "3m");
Duration staleAge = duration("payments.reconciliation.stale-age", "3h");
+ int maxPerRun = environment.getProperty("payments.reconciliation.max-per-run", Integer.class, 10);
+ LocalDateTime pendingCutoff = now.minus(pendingAge);
// Order matters: close off the hopeless ones first so the re-query pass below
// does not keep hammering the operator for payments we have given up on.
@@ -59,7 +61,21 @@ public class PaymentReconciliationJob {
log.warn("{} initiation(s) still pending after {} — marked Unresolved", count, staleAge);
}
})
- .thenMany(lifecycle.findPendingOlderThan(now.minus(pendingAge)))
+ // Capped, oldest first. Uncapped, a backlog of N open payments queues N
+ // provider status queries in one tick; at the operator's pace that is
+ // minutes of work per 60-second tick, and it holds the entire call
+ // budget indefinitely while real payments queue behind it.
+ .thenMany(lifecycle.countPendingOlderThan(pendingCutoff)
+ .doOnNext(waiting -> {
+ if (waiting > maxPerRun) {
+ // Say what was left behind — a silent cap reads as
+ // "everything was reconciled" when it was not.
+ log.info("{} initiation(s) pending past {} — reconciling the {} oldest this run, "
+ + "{} deferred to later runs",
+ waiting, pendingAge, maxPerRun, waiting - maxPerRun);
+ }
+ })
+ .thenMany(lifecycle.findPendingOlderThan(pendingCutoff, maxPerRun)))
.concatMap(initiation -> {
PaymentProviderService service = servicesByProvider.get(initiation.getProvider());
if (service == null) {
diff --git a/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java
index be4962e..d839ea0 100644
--- a/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java
+++ b/src/main/java/com/test/payment/repository/PaymentInitiationRepository.java
@@ -2,6 +2,7 @@ package com.test.payment.repository;
import com.test.payment.models.PaymentInitiation;
import com.test.payment.models.PaymentProviderType;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -18,12 +19,31 @@ import java.util.List;
@Repository
public interface PaymentInitiationRepository extends JpaRepository Both matter for reconciliation. Unbounded, one tick queues a provider status
+ * query for every open payment — with a backlog of 60 that is minutes of work per
+ * 60-second tick, and it consumes the operator's whole call budget indefinitely.
+ * Oldest-first makes the backlog drain FIFO instead of re-querying the same head
+ * of the list every tick. Pass {@code Pageable.unpaged()} for the database-only
+ * sweeps, where there is no per-row cost to bound.
+ */
@Query("""
SELECT i FROM PaymentInitiation i
WHERE i.Status.Name = :statusName AND i.CreatedAt < :cutoff
+ ORDER BY i.CreatedAt ASC
""")
List If one of our own guards refused the call — the circuit breaker is open, or the
+ * rate limiter would not issue a permit — the operator never saw the request and no
+ * payment was attempted. That is Rejected, not Failed. Recording it as Failed
+ * produces rows that read as failed payments when nothing was sent, and they carry
+ * no provider reference, so nothing can ever resolve them.
+ *
+ * Anything else did reach the operator, so it is a real failure.
+ */
+ public Mono Separate from {@link #markFailed} on purpose. NOT_SENT is distinguishable from
+ * ERROR (the operator was called and something went wrong) and from REJECTION (the
+ * operator was called and declined the request), so a burst of locally-rejected
+ * calls cannot be mistaken for a wave of failing payments.
+ */
+ @Transactional
+ public void markNotDispatched(Long initiationId, String reason) {
+ PaymentInitiation updated = updateStatus(requireInitiation(initiationId), statuses.rejected());
+ recordTransaction(updated, null, null, truncate(reason), null, null, "NOT_SENT");
+ }
+
@Transactional
public CallbackAckDto applyCallback(PaymentProviderType provider, CallbackData data, String rawPayload) {
Optional Deliberately not Failed. Failed means the operator saw the payment and it did
+ * not succeed; this means the operator never saw it at all. Recording these as
+ * Failed produces rows that read as failed payments when nothing was ever sent —
+ * and there is no provider reference on them, so nothing can ever resolve them.
+ */
+ public Status rejected() {
+ return require(REJECTED);
+ }
+
/**
* Whether a payment may move to {@code target}. Guards against duplicate and
* out-of-order status updates: re-applying the state it already has is a no-op,
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index fa272a9..e9de09d 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -5,9 +5,25 @@ resilience4j:
ratelimiter:
instances:
mpesaLimiter:
- limit-for-period: 5
- limit-refresh-period: 1s
- timeout-duration: 5s
+ # Calibrated to Safaricom's actual Apigee spike arrest, observed live on the
+ # STK query endpoint: "messagesPerPeriod=30, periodInMicroseconds=60000000,
+ # maxBurstMessageCount=3" — 30 calls a minute, i.e. one every 2 seconds.
+ #
+ # Spike arrest enforces SPACING, not a bucket you drain, so this is 1-per-2s
+ # rather than 30-per-60s: the latter would let 30 calls fire at the top of the
+ # minute and every one after the third would come back 429.
+ #
+ # The previous 5/s was 300/min — 10x more permissive than the provider — so
+ # the limiter granted every permit right up until Safaricom rejected the call.
+ # A local limiter looser than the remote one protects nothing.
+ #
+ # Sandbox and production quotas differ, and this was measured on the query
+ # endpoint; raise it if your production API product is provisioned higher.
+ limit-for-period: 1
+ limit-refresh-period: 2s
+ # A permit now takes up to 2s to arrive, so the wait has to exceed that or
+ # calls would be refused while a permit was about to free up.
+ timeout-duration: 10s
airtelLimiter:
limit-for-period: 5
limit-refresh-period: 1s
@@ -43,6 +59,11 @@ resilience4j:
ignore-exceptions:
- com.test.payment.exceptions.ProviderPermanentException
- com.test.payment.exceptions.ProviderProcessingException
+ # Being throttled is not the provider being unhealthy — it answered, promptly
+ # and correctly, to say "slow down". Counting 429s as failures turns a rate
+ # limit into an outage: the breaker opens, and calls we would otherwise have
+ # paced correctly are rejected without being attempted.
+ - com.test.payment.exceptions.ProviderBusyException
airtelCircuitBreaker: *provider-circuit-breaker
mtnCircuitBreaker: *provider-circuit-breaker
@@ -68,7 +89,19 @@ spring:
logging:
level:
- org.hibernate.SQL: DEBUG
+ # Every statement Hibernate issues — off by default because it is one line per
+ # query and drowns everything else. Uncomment to debug a query.
+ # org.hibernate.SQL: DEBUG
+ # Retry attempts, circuit-breaker state changes, rate-limiter permits and the
+ # ProviderBusyException backoff all log through this one name
+ # (ResilienceEventLogging + ProviderBackoff). INFO gives registrations, retries,
+ # refusals and state transitions; DEBUG adds the per-call chatter — every permit
+ # granted with the remaining headroom in the refresh window, and every recorded
+ # success/failure the breaker counts.
+ com.test.payment.resilience: DEBUG
+ # Token tier hits: served from Redis / from the database / fetched fresh.
+ # The fetch itself is INFO; which tier answered is DEBUG.
+ com.test.payment.service.TokenCacheService: DEBUG
springdoc:
swagger-ui:
@@ -81,11 +114,27 @@ springdoc:
payments:
token-expiry-buffer-seconds: 60
+ # How often a client should re-check a payment that is still PENDING.
+ # Served to clients by GET /api/payments/config so the poll rate is set here,
+ # next to the rate limits it has to live within, rather than hardcoded per client.
+ #
+ # Every poll of a PENDING payment is a live provider call, so this spends the same
+ # 30/min budget as the payments themselves: at 15s one open payment costs 4 calls a
+ # minute. The 5s it replaces cost 12 — 40% of the budget on a single idle payment.
+ status-poll-interval: 15s
+ # Consolidated-transaction refresh. A database read, no provider call, so it is
+ # only bounded by how much load you want on your own app.
+ transaction-refresh-interval: 10s
reconciliation:
# payments still Pending after this age are re-queried against the provider
pending-age: 3m
# after this long with no outcome we stop querying and mark them Unresolved
stale-age: 3h
+ # Most payments one run will re-query. Each one is a live provider call, so this is
+ # the job's share of the operator's budget: at 10 per 60s tick it takes a third of
+ # the 30/min ceiling and leaves the rest for actual payments. Uncapped, a backlog
+ # of 60 open payments queued 60 calls per tick and starved everything else.
+ max-per-run: 10
# how often the reconciliation job runs
fixed-delay: 60s