Adam Saghy created FINERACT-2985:
------------------------------------
Summary: [BE] PL / WC - Add Recoverable Error for Retry Create
Account Failure
Key: FINERACT-2985
URL: https://issues.apache.org/jira/browse/FINERACT-2985
Project: Apache Fineract
Issue Type: Improvement
Components: System
Affects Versions: 1.16.0
Reporter: Adam Saghy
Assignee: Adam Saghy
Fix For: 1.17.0
h3. Problem
Retrying POST /clients with the same Idempotency-Key near midnight keeps
failing with error.msg.clients.activationDate.in.the.future. The activation
date is the request date, but the business-date increment job hasn’t finished
yet. The failure is saved in m_portfolio_command_source as ERROR under that
key. Every later retry with the same key replays the cached failure, even after
the business date has moved on.
h3. Approach
A failed command whose error is classified as *recoverable* is still saved with
status ERROR. In the same save, its idempotency key is renamed by adding
_RE<n>, where <n> is the number of recoverable failures so far for that key:
||Attempt||Outcome||Row saved||
|1|recoverable failure|abc_RE1, ERROR|
|2|recoverable failure|abc_RE2, ERROR|
|3|success|abc, PROCESSED|
|4|—|replayed from cache (abc, PROCESSED)|
The original key is free again, so the next retry follows the normal path:
* *No new status and no special path.* CommandProcessingResultType is
unchanged and an error is still ERROR. Nothing outside this logic needs to know
about recoverable errors.
* *No parallel execution of the same request.* Every attempt goes through
saveInitial, which inserts an UNDER_PROCESSING row under the original key. A
concurrent request with the same key hits UNIQUE_PORTFOLIO_COMMAND_SOURCE and
gets the existing 409 “under processing” response. The key is renamed only
after the attempt has finished, so at most one attempt per key runs at any time.
* *A full audit trail.* Each attempt keeps its own row with its own maker,
payload, timestamp and result. A retry never overwrites an earlier attempt, so
maker-checker always sees the user who really submitted it.
* *Non-recoverable failures behave as today.* The key isn’t renamed and the
cached failure is replayed.
h3. Which errors are recoverable
An error is recoverable if either of these is true:
# The exception class implements the marker interface
org.apache.fineract.infrastructure.core.exception.RecoverableError. This covers
errors that are recoverable by nature, decided in code.
# Its userMessageGlobalisationCode is in the global configuration
idempotency-recoverable-error-codes. This is a comma-separated string_value in
c_configuration, read the same way as the existing list-valued settings in
ConfigurationDomainServiceJpa.
** For PlatformApiDataValidationException, *every* entry in getErrors() must
be in the list. If any entry is not, the error is not recoverable.
** For other AbstractPlatformException subclasses, the exception’s own
globalisation code is checked.
** Default: enabled, with
error.msg.clients.activationDate.in.the.future,error.msg.group.activationDate.in.the.future.
The group code also covers centres.
The check lives in one component, RecoverableErrorClassifier. It looks at the
mapped exception (ErrorHandler.getMappable).
h3.
h3. Acceptance criteria
# RecoverableError.java (new): marker interface.
# RecoverableErrorClassifier.java (new): the classification rules above.
# Liquibase changeset (new):
** Adds the idempotency-recoverable-error-codes global configuration.
** Widens m_portfolio_command_source.idempotency_key from VARCHAR(50) to
VARCHAR(64), so a full-length client key still fits with the suffix. Update
@Column(length = …) in CommandSource to match.
# ConfigurationDomainService / ConfigurationDomainServiceJpa: add a getter
that returns the configured codes, or an empty set when the setting is disabled.
# CommandSourceRepository: add a count of rows with the same action_name and
entity_name whose idempotency_key matches <key>_RE%. Escape _ and % in the LIKE
pattern.
# SynchronousCommandProcessingService.persistFinalErrorResult: if the error is
recoverable, set status ERRORand idempotencyKey = key + "_RE" + (count + 1),
then save in one saveAndFlush. Nothing else in the class changes:
** exceptionWhenTheRequestAlreadyProcessed stays as it is.
** Retries inside the same request still reuse the row through
COMMAND_SOURCE_ID, because the rename happens only after the last attempt.
** Inside an enclosing batch transaction nothing is saved today, so nothing is
renamed.
** IdempotencyStoreFilter later writes the response body by row id, so the
renamed row still gets its final body.
# Collision handling: if a client used a literal key like abc_RE1 itself, the
rename can hit the unique constraint. In that case, try the next number a few
times. If that still fails, leave the key unchanged, which is today’s
behaviour. Optionally, reject incoming keys that match _RE\d+$.
h3. Tests
Unit tests:
* RecoverableErrorClassifierTest should cover:
** the marker interface → recoverable
** all validation codes configured → recoverable
** mixed codes → not recoverable
** setting disabled → not recoverable
** a domain-rule exception with a configured code → recoverable
** an unrelated exception → not recoverable
* SynchronousCommandProcessingServiceTest should cover:
** a recoverable failure saves ERROR with the key renamed to idk_RE1
** when one renamed row already exists, the new key is idk_RE2
** a non-recoverable failure keeps the key
** an enclosing transaction saves nothing
** the collision fallback
* Drop the unit tests that relied on
RECOVERABLE_ERROR(recoverableErrorStoresCommandSourceIdForRetry,
retryReusesRecoveredCommandSource).
Integration tests (ClientActivationIdempotencyRetryIntegrationTest):
* With a lagged business date the request returns 400. After the business date
moves on, a retry with the same key returns 200 and isn’t served from cache. A
third call with the same key is served from cache and returns the same clientId.
* Two recoverable failures followed by a success: both failed attempts are
still in the audit trail with their 400 results, next to the processed one. The
audit API doesn’t expose the idempotency key, so check this by action, entity
and maker.
* Two concurrent requests with the same key after a recoverable failure: one
gets 200, the other gets 409, and exactly one client is created.
* A code that isn’t in the configured list still replays the cached failure.
--
This message was sent by Atlassian Jira
(v10.3.26#10030026)