Files
Webflux-Payments/.gitignore
spiro-alvin-nyasimi c932c98ae6 Stop the provider-throttling feedback loop, and make the guards audible
Sustained load against the M-Pesa sandbox put the service into a spiral:
throttling opened the circuit breaker, the breaker recorded untried payments
as Failed, and an uncapped reconciliation sweep held the operator's entire
call budget while real payments queued behind it.

Resilience observability
- ResilienceEventLogging registers RegistryEventConsumers for Retry,
  CircuitBreaker and RateLimiter. Resilience4j publishes events but logs
  nothing, so retries, state transitions and refused permits were invisible.
  Subscribing at registry level catches instances as they are created lazily.
- ProviderBackoff extracts the ProviderBusyException backoff that all three
  clients duplicated, and logs each wait.
- Everything routes through one logger name, com.test.payment.resilience.
- org.hibernate.SQL logging off by default.

Breaking the loop
- ProviderBusyException is ignored by the circuit breaker. Being throttled is
  not the provider being unhealthy; counting 429s as failures turned a rate
  limit into a self-inflicted outage.
- New Rejected status with resolvedBy = NOT_SENT for calls an open breaker or
  a refused permit turned away. These never reached the operator, so recording
  them as Failed produced rows that read as failed payments when nothing was
  sent — and with no provider reference, nothing could ever resolve them.
- Reconciliation sweeps at most payments.reconciliation.max-per-run (10)
  initiations per tick, oldest first, and logs what it deferred. Uncapped, a
  backlog of 60 queued 60 provider calls per 60-second tick.

Rate limits calibrated to the provider
- mpesaLimiter is 1 permit per 2s, not 5 per second. Safaricom's Apigee spike
  arrest allows 30/minute with a burst of 3, so the old setting was 10x more
  permissive than the provider and never protected anything. Spike arrest
  enforces spacing, so this is 1-per-2s rather than 30-per-60s.
- Client poll rates move to payments.status-poll-interval and are served by
  GET /api/payments/config, so clients stop guessing at a budget only the
  server can see. providerCallsPerMinute is derived from the limiter config.

Verified against a mock provider: the capped sweep reconciles exactly N oldest
and reports the rest; 429s no longer open the breaker; breaker-rejected calls
land as Rejected/NOT_SENT while genuine provider errors stay Failed/ERROR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:37:24 +03:00

96 lines
1.5 KiB
Plaintext

# --- Gradle ---
build/
.gradle/
gradle-app.setting
.gradletasknamecache
# --- IDE: IntelliJ ---
.idea/
*.iml
*.ipr
*.iws
out/
# --- IDE: Eclipse / STS ---
.apt_generated
.classpath
.factorypath
.project
.settings/
.springBeans
.sts4-cache
bin/
# --- IDE: VS Code ---
.vscode/
# --- IDE: NetBeans ---
/nbproject/private/
nbbuild/
dist/
nbdist/
.nb-gradle/
# --- OS ---
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
Desktop.ini
# --- Logs & temp ---
*.log
logs/
nohup.out
hs_err_pid*
replay_pid*
*.hprof
.attach_pid*
*.tmp
*.bak
*.swp
*~
# --- H2 database files (if switching from in-memory to file-based) ---
*.mv.db
*.trace.db
*.lock.db
# --- Compiled / packaged ---
*.class
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
*.ctxt
.mtj.tmp/
# Keep the Gradle wrapper despite the *.jar rule above (last match wins)
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
# --- Local env / secrets (keep real credentials out of the repo) ---
.env
.env.*
application-local.yml
application-local.yaml
application-secrets.yml
application-secrets.yaml
*.pem
*.p12
*.pfx
*.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/