Retry and Resilience Patterns - phax/phoss-ap GitHub Wiki
Retry and Resilience Patterns
Overview
This page describes the retry strategies and resilience patterns used by phoss-ap for outbound AS4 sending and inbound forwarding, including the circuit breakers that guard the SMP lookup, the outbound AS4 sending and the inbound forwarding.
Outbound Sending Retry
When an AS4 sending attempt fails, the outbound transaction is marked as failed and scheduled for retry by the periodic retry scheduler.
Retry Strategy
The retry strategy is configurable via Java interfaces and the following properties:
retry.sending.max-attempts— Maximum number of attempts before permanent failureretry.sending.initial-backoff.ms— Initial backoff intervalretry.sending.backoff-multiplier— Exponential backoff multiplierretry.sending.max-backoff.ms— Maximum backoff capretry.scheduler.interval.ms— How often the scheduler checks for eligible retries
Each failed attempt creates a new outbound_sending_attempt row. The next_retry_dt on the parent outbound_transaction is computed using the backoff strategy and stored in the database, so the scheduler can efficiently query WHERE status = 'failed' AND next_retry_dt <= NOW().
Multi-Instance Safety
The retry scheduler uses SELECT ... FOR UPDATE SKIP LOCKED when picking up eligible transactions. This ensures that when multiple AP instances run concurrently, each transaction is retried by exactly one instance. No leader election is needed — all instances run the scheduler, and the database coordinates work distribution.
phase4 Internal HTTP Retry Disabled
phase4 offers its own internal HTTP retry mechanism (HttpRetrySettings). In phoss-ap this is set to 0 (disabled), because all retry logic is handled at the application level by the phoss-ap retry scheduler. This ensures a clean separation: each phoss-ap retry attempt creates exactly one outbound_sending_attempt row with its own AS4 Message ID.
The circuit breakers (see below) also operate at the application level, gating whether a new sending attempt should be initiated at all.
Inbound Forwarding Retry
When forwarding a received document to the Receiver Backend fails, the inbound transaction is marked as forward_failed and scheduled for retry.
Retry Strategy
Analogous to outbound, configured via:
retry.forwarding.max-attempts— Maximum number of attempts before permanent failureretry.forwarding.initial-backoff.ms— Initial backoff intervalretry.forwarding.backoff-multiplier— Exponential backoff multiplierretry.forwarding.max-backoff.ms— Maximum backoff cap
Each failed attempt creates a new inbound_forwarding_attempt row. The next_retry_dt on the parent inbound_transaction is updated accordingly.
Deferred Verification Retry (since v0.12.0)
With verification.verifier-fail-mode=deferred, an inbound document whose verifier backend service was unavailable is parked in status verification_deferred instead of being rejected. The same scheduler cycle that drives the forwarding retries also re-verifies these documents.
- Selection:
status = verification_deferred AND next_retry_dt <= NOW(), batched withFOR UPDATE SKIP LOCKED— like the forwarding retry. - Schedule: a fixed interval (
verification.deferred.retry-interval, default5m) instead of exponential backoff — the AP is waiting for a service to come back, not backing off from a rejecting peer. - Bound: a wall-clock deadline (
verification.deferred.max-duration, default12h, relative toreceived_dt) instead of an attempt counter. When it is exceeded, the document is rejected and the negative MLS is sent, so C2 always gets a final answer. - The forwarding
attempt_countis deliberately not touched by a deferral, so a long verifier outage does not consume the forwarding retry budget.
Once the verifiers accept the document, the processing continues where it was interrupted — MLS correlation, forwarding to C4 and the positive MLS to C2.
Multi-Instance Safety
Same SKIP LOCKED approach as outbound retry — safe for concurrent instances without coordination.
MLS Watchdog (since v0.13.0)
With mls.sending.trigger=api the positive MLS of a successfully forwarded document is not sent by the AP but by the Receiver Backend, via POST /api/mls/send. A backend that stays silent would leave the document without any MLS at all, so the same scheduler cycle carries a watchdog that answers C2 on its behalf.
- Selection:
status = forwarded AND mls_response_code IS NULL AND as4_timestamp < NOW() - mls.sending.api.timeout, excluding inbound MLS and MLR documents, everything withverification_result = rejectedand everything withmls_type = FAILURE_ONLY. Batched withFOR UPDATE SKIP LOCKED, like the other cycles, and it needs no schema change — the existing columns carry all the information. - The age is measured on
as4_timestampand not oncompleted_dt, because MLS-1 is measured from the reception of the document. Anchoring on the forwarding would add the forwarding duration on top of the SLA budget instead of consuming it from it: a document that only reached C4 after 15 minutes of retries would then be answered at minute 20 at the earliest. A slow forwarding therefore shortens the window of the backend, and in the extreme case the fallback goes out with the first cycle after the forwarding — which is the correct answer when there is no budget left. - A
FAILURE_ONLYtransaction is excluded because it never gets a positive MLS at all. Recording a fallback response code for it would put nothing on the wire and only block the rejection that the Receiver Backend may still report. - Schedule: no backoff and no attempt counter. Each transaction is answered once; the claimed
mls_response_codetakes it out of the selection. - Bound: the response code of the fallback is
mls.sending.api.timeout.code(defaultAB— the document was accepted, the delivery to C4 was never confirmed). - The fallback exists for compliance: MLS-1 of the Peppol Network Policy requires 99.5 % of all MLS responses within 20 minutes of the reception of the business document, so the default timeout of
5mdeliberately leaves room for the AS4 sending and its retries.
Because the check runs inside the existing cycle, the effective delay is up to one retry.scheduler.interval longer than the configured timeout.
Manual Replay (since 0.11.0)
Once the automatic retries are exhausted (status permanently_failed), forwarding can be re-triggered manually via POST /api/ops/inbound/{sbdhInstanceID}/replay. The replay runs through the same forwarding pipeline as a scheduled retry — including circuit breaker, attempt recording and notification handlers — and is not restricted to failed transactions, so it can also re-deliver an already forwarded document. See API Specification.
Circuit Breakers
Motivation
When a remote system is down or unreachable, repeatedly calling it wastes resources and delays the processing of everything else. A circuit breaker prevents that by temporarily blocking calls to an unhealthy remote system, giving it time to recover.
The three circuit breakers
All of them are managed by CircuitBreakerManager in phoss-ap-core, implemented in-memory with Failsafe and identified by a key:
| Key | Guards | Opened by |
|---|---|---|
smp$<SMP host URI> |
The SMP lookup of an outbound transaction | Failures of the SMP infrastructure only — see below |
ap$<C3 endpoint URL> |
The outbound AS4 transmission | A failed AS4 transmission |
phoss-ap-forwarder |
The inbound forwarding to the Receiver Backend | A failed forwarding |
The SMP and the AP circuit breaker are keyed by the resolved host respectively endpoint URL, not by the receiver participant ID, because many participants share the same SMP and the same AP. The forwarding circuit breaker is a single one, because the forwarding target is configured per AP instance.
Design
State transitions follow the standard circuit breaker pattern:
CLOSED ──(failure threshold reached)──> OPEN
OPEN ──(open duration expires)──> HALF_OPEN
HALF_OPEN ──(probe succeeds)──> CLOSED
HALF_OPEN ──(probe fails)──> OPEN
- Closed: Normal operation. Failures are counted.
- Open: Every call is rejected immediately. The transaction stays in
failedrespectivelyforward_failedstatus withnext_retry_dtset. - Half-open:
circuit-breaker.half-open-max-attemptsprobe calls are allowed through. If they succeed, the circuit breaker closes. If they fail, it reopens.
The orchestrators use the circuit breaker directly rather than through a phase4 extension point: CircuitBreakerManager.tryAcquirePermit (key) is called before the remote system is contacted, and exactly one of recordSuccess (key) / recordFailure (key, cause) is called afterwards, on every code path.
Every acquired permit must be recorded (since v0.13.0). A permit that is acquired in the half-open state and never recorded is never released again — Failsafe releases it only in
recordSuccess/recordFailure, there is no timeout. With the defaultcircuit-breaker.half-open-max-attempts=1a single leaked permit therefore leaves the circuit breaker stuck inHALF_OPENforever: every call is rejected, no state change event is logged anymore, and only a restart clears it. All three usages are wrapped in atry/finallythat records a failure if nothing else did.
What counts as a failure (since v0.13.0)
For the SMP circuit breaker, the outcome of a lookup is classified by SmpLookupFailureClassifier before it is recorded:
| Outcome | Circuit breaker | Transaction |
|---|---|---|
| The receiver or the service is not registered (HTTP 404, or no endpoint matches the process and transport profile) | recordSuccess — the SMP answered, this is a valid answer |
Permanent failure (or the MLS_TO fallback for an MLS) |
| The SMP is unavailable: connection failure, connect or read timeout, unknown host, HTTP 5xx, unparsable or badly signed response | recordFailure |
Retried |
| Anything else, e.g. HTTP 400 or 403, or a certificate that cannot be extracted | recordFailure |
Per Phase4Exception.isRetryFeasible () |
Before v0.13.0 every exception counted as a failure, so a handful of lookups for unregistered receivers suspended a large SMP for all of its participants. The retry decision is deliberately taken away from isRetryFeasible () for the first two rows: phase4 reports a "not registered" answer as retry feasible and an SMP timeout as not retry feasible, which is the wrong way round for both.
Rejections do not consume a retry attempt (since v0.13.0)
A call that is rejected by a circuit breaker never reaches the remote system, so it must not count as an attempt:
- No
outbound_sending_attempt/inbound_forwarding_attemptrow is created. - The
attempt_countof the transaction is left unchanged. - The status is set to
failedrespectivelyforward_failedandnext_retry_dtis set tonow + max (remaining open duration, retry scheduler interval), so the retry happens as soon as the circuit breaker may grant a permit again. - The rejection message is stored in
error_detailsand in the sending report, so the deferral stays visible.
To keep a permanently unreachable remote system from deferring a transaction forever, the deferral only applies while the transaction is younger than circuit-breaker.defer-max-duration (default 12h). An older transaction falls back to the regular attempt counting.
Rejection messages (since v0.13.0)
CircuitBreakerManager remembers per key when the circuit breaker was opened and what the last failure was, and builds a message an operator can act on:
SMP access to 'https://smp.example.org' suspended by circuit breaker
(state OPEN since 2026-09-14T10:12:03Z, 42s remaining) after 5 failures;
last failure: SocketTimeoutException: Read timed out after 10000 ms
The same message goes into the log, the transaction error_details and the sending report. onOpen is logged at WARN including the last failure cause; onHalfOpen and onClose stay at INFO.
Configuration
See Configuration Properties for the full list, including the optional time based and rate based thresholding added in v0.13.0.
| Property | Description | Default |
|---|---|---|
circuit-breaker.failure-threshold |
Number of consecutive failures before opening | 5 |
circuit-breaker.open-duration |
How long it stays open before transitioning to half-open | 1m |
circuit-breaker.half-open-max-attempts |
Number of probe attempts allowed in half-open state | 1 |
circuit-breaker.defer-max-duration |
Maximum transaction age for which a rejection does not consume an attempt | 12h |
circuit-breaker.failure-executions |
Executions the failure threshold is measured over | unset |
circuit-breaker.failure-period |
Rolling time window the failure threshold is measured over | unset |
circuit-breaker.failure-rate |
Failure rate in percent at which it opens | unset |
The configuration is read when a circuit breaker is created. Changing a property therefore only affects keys that are used for the first time afterwards — existing circuit breakers keep their settings until the AP is restarted or the key is reset.
In-Memory Design
The state is held purely in-memory (ConcurrentHashMap keyed by the circuit key). This means:
- State is lost on AP restart — this is intentional, as a restart is itself a recovery event.
- No database persistence overhead.
- Thread-safe for concurrent calls to different remote systems.
- In a multi-instance deployment, each instance maintains its own independent state. This is acceptable — each instance protects itself, and different instances may have different views of a remote system's health.
Runtime visibility (since v0.13.0)
GET /api/ops/circuit-breakerslists every known circuit breaker with its key, state, open-since timestamp, remaining delay, failure count and last failure cause.POST /api/ops/circuit-breakers/{circuitKey}/resetforgets a single circuit breaker, so the next call creates a new, closed one from the current configuration. This is also the way to apply a changedcircuit-breaker.*property without a restart.- The counters
phoss.ap.circuit_breaker.rejectionsandphoss.ap.circuit_breaker.state_changesare tagged with the circuit breaker key and the state — see OpenTelemetry Integration.
Both endpoints are part of the Operations API and require the X-Token header. See API Specification.
Module Placement
| Concern | Module |
|---|---|
| Circuit breaker manager (in-memory state, Failsafe integration) | phoss-ap-core |
| Retry strategy interface | phoss-ap-api |
| Retry scheduler | phoss-ap-core |
| Circuit breaker configuration properties | phoss-ap-webapp (application.properties) |
The Failsafe library dependency is added to phoss-ap-core only.