Working with External Services
Handling Timeouts and External Failures
In the previous lesson, we introduced:
PaymentService
↓
ProviderPaymentService
↓
ProviderPaymentClient
↓
External Payment Provider
and established an important distinction:
confirmed payment rejection
is not the same as:
provider communication failure
Now we need to go deeper.
External systems introduce failure modes that do not exist when all work happens inside one PostgreSQL transaction.
Consider these situations:
provider rejects payment
provider returns 500
connection cannot be established
request times out
response is malformed
response status is unknown
provider processes payment
but response is lost
These scenarios cannot all be treated as:
payment failed
Some give us a known business outcome.
Others mean:
we do not know
what happened externally
The goal of this lesson is:
Classify external-service failures according to what the application actually knows, so confirmed payment outcomes, infrastructure failures, and uncertain outcomes are never collapsed into one misleading result.
External Calls Change Failure Semantics
With local PostgreSQL work, a transaction gives us strong information.
For example:
UPDATE inventory
→ transaction commits
means the state was committed.
If the transaction rolls back:
the update was not committed
The application controls that local transaction boundary.
An external provider is different.
Conceptually:
Our Backend
↓
network
↓
Payment Provider
There are now several independent systems involved:
our process
our network connection
provider edge
provider application
provider persistence
A failure at one layer does not necessarily tell us what happened at another.
The Most Important Question
When an external call fails, ask:
What do we actually know about the external side effect?
Not:
Which exception was thrown?
The exception matters operationally.
But business behavior depends on:
what is known
about the payment outcome
A useful classification is:
1. Confirmed success
2. Confirmed business failure
3. Technical failure before
external execution is known
4. Uncertain outcome
5. Invalid / unexpected
provider response
These categories carry different meaning.
1. Confirmed Success
The provider explicitly confirms that the payment succeeded.
Conceptually:
Provider
→ confirmed success
The Payment integration translates this to:
PaymentResult
→ SUCCEEDED
Only then may the application later perform:
order.markPaid();
This is the strongest payment outcome we can receive.
Confirmed Success Must Be Explicit
We should never infer success merely from:
no exception thrown
or:
HTTP 200
unless the actual provider contract says that combination means successful payment.
For example, a provider could respond:
HTTP 200
with business body:
payment rejected
Therefore:
transport success
and:
payment success
remain different concepts.
2. Confirmed Business Failure
Suppose the provider clearly tells us:
payment rejected
Then the integration has successfully communicated with the provider.
The provider has given us a definitive business result:
payment did not succeed
The application can represent:
PaymentResult
→ FAILED
The Order remains:
UNPAID
No system malfunction is implied.
Confirmed Failure Is Not an Exception by Necessity
A normal payment rejection may be part of expected behavior.
Examples could include provider-defined cases such as:
payment not approved
payment method rejected
provider refuses transaction
The exact reasons depend on the provider contract.
The important point is:
provider communicated normally
+
outcome is definitively unsuccessful
That can be represented as an application result rather than a system error.
Do Not Mark Order Cancelled
Confirmed payment failure means:
Order remains UNPAID
It does not mean:
Order becomes CANCELLED
Cancellation is a separate Customer business action with Inventory restoration consequences.
Payment rejection and Order cancellation are different concepts.
3. Technical Failure
Now suppose the integration cannot communicate normally.
Examples:
DNS resolution fails
connection refused
TLS negotiation fails
provider returns unusable 5xx
serialization fails before request is sent
These are technical/infrastructure failures.
They are not equivalent to:
provider rejected payment
The system could not obtain a normal payment result.
A Technical Failure Is Not FAILED
Bad:
try {
return providerClient.pay(
request
);
} catch (Exception exception) {
return PaymentResult.failed();
}
This translates:
provider unavailable
into:
payment rejected
That is false.
It hides:
production outage
network issue
configuration error
integration bug
behind a business result.
Preserve System Failure
Instead:
normal provider rejection
→ PaymentResult.FAILED
while:
technical provider failure
→ integration exception
This follows the error model we already established:
Expected business outcomes and infrastructure failures must remain distinct.
4. Uncertain Outcome
This is the most dangerous category.
Consider this timeline:
1. Backend sends payment request.
2. Provider receives request.
3. Provider successfully charges Customer.
4. Provider sends response.
5. Network connection breaks.
6. Backend times out.
What does our backend know?
Only:
we did not receive
a confirmed response
It does not know:
payment failed
The payment may already have succeeded.
Timeout Means Lack of Knowledge
This is the central principle:
A timeout tells us that our waiting period expired. It does not necessarily tell us what happened at the provider.
Timeout can occur:
before provider receives request
or:
while provider processes request
or:
after provider commits success
but before response reaches us
From the client's perspective these may all appear similar.
Their external side effects are very different.
Timeout Is Not Confirmed Failure
Therefore this is wrong:
catch (
PaymentProviderTimeoutException exception
) {
return PaymentResult.failed();
}
Because:
FAILED
means:
we know payment did not succeed
A timeout often means:
we do not know
Those are not equivalent.
Timeout Is Not Confirmed Success Either
Likewise, we cannot say:
provider probably processed it
→ mark Order PAID
without confirmation.
That could result in:
Order = PAID
while:
Customer was never charged
So after an unresolved timeout:
do not mark Order PAID
until the workflow has sufficient evidence.
Why This Is Harder Than Database Timeout
Suppose a local database transaction fails before commit.
Our database protocol and transaction boundary generally give us a controlled local recovery model.
With a remote service:
our transaction manager
does not control:
provider persistence
The network can fail after the external side effect has already occurred.
That creates:
outcome ambiguity
We Need a Failure Taxonomy
A useful internal conceptual taxonomy is:
Payment outcome
----------------
SUCCEEDED
FAILED
Integration failure
-------------------
CONNECTION_FAILURE
TIMEOUT
PROVIDER_UNAVAILABLE
INVALID_RESPONSE
UNEXPECTED_RESPONSE
But we should be careful:
This does not mean we need a huge public enum or dozens of exception classes.
The purpose is reasoning.
Implementation can remain small while preserving the distinctions that matter.
Application Result vs Integration Failure
Our application-facing method may still conceptually be:
PaymentResult pay(
PaymentRequest request
);
with normal results:
SUCCEEDED
FAILED
while technical failures use exceptions.
For example:
PaymentProviderException
and perhaps a specific subtype for:
timeout / uncertain outcome
because timeout may need different later recovery behavior.
Why Timeout May Deserve a Specific Type
A generic:
PaymentProviderException
may be enough for many technical failures.
But timeout is operationally special because:
external side effect
may already have happened
That can influence:
retry
reconciliation
customer response
logging
alerting
So preserving timeout explicitly can be valuable.
Example Exception Model
Conceptually:
public class PaymentProviderException
extends RuntimeException {
public PaymentProviderException(
String message,
Throwable cause
) {
super(
message,
cause
);
}
}
Then:
public final class
PaymentProviderTimeoutException
extends PaymentProviderException {
public PaymentProviderTimeoutException(
Throwable cause
) {
super(
"Payment provider request timed out",
cause
);
}
}
We do not need:
twenty exception subclasses
before they support real workflow behavior.
Connection Failure
Consider:
connection refused
This often means:
provider request
could not be delivered
But even here we should avoid making overly strong claims unless the HTTP client can establish exactly when failure occurred.
Operationally it is:
technical failure
The application should not map it to normal payment rejection.
DNS Failure
If the provider hostname cannot resolve:
DNS failure
then this is clearly infrastructure/configuration territory.
Potential causes:
bad provider URL
network DNS outage
deployment misconfiguration
Customer payment behavior should not hide this as:
FAILED payment
Operators need to see the actual issue.
TLS Failure
Likewise:
certificate validation failure
TLS handshake error
means our integration could not establish secure communication.
This is a system failure.
Do not degrade security by:
disabling certificate validation
just to make the payment call succeed.
Provider 5xx
A provider:
500
502
503
usually indicates provider-side technical trouble.
Our default interpretation should be:
technical integration failure
unless the provider's documented contract gives more specific payment semantics.
We must not say:
5xx
→ payment declined
by default.
Provider 4xx
Provider 4xx responses are more subtle.
Depending on the actual API they may mean:
invalid integration request
authentication problem
business rejection
unsupported request
So we cannot establish a generic rule:
all 4xx
→ FAILED
The provider contract determines which cases represent:
normal payment business outcome
and which represent:
our integration/configuration defect
Example: Wrong Credentials
Suppose the provider returns:
401
because:
our configured API credential
is invalid
That is not the Customer's payment failing.
It is:
our integration configuration
failing
Mapping that to:
PaymentResult.FAILED
would hide an operational incident.
Example: Invalid Provider Request
Suppose the provider says:
required provider field missing
even though our application generated the request.
That likely indicates:
integration bug
not Customer business failure.
Again:
system/integration failure
is the honest classification.
Invalid Provider Response
Suppose provider returns:
HTTP 200
but the response cannot be parsed.
Maybe:
invalid JSON
missing required field
unexpected schema
We cannot know whether the payment outcome is:
success
failure
from a response we cannot understand.
Therefore:
invalid response
→ integration failure
not:
FAILED
and definitely not:
SUCCEEDED
Unknown Provider Status
Imagine the provider previously documented:
SUCCESS
DECLINED
but now returns:
REVIEW
If our integration does not understand REVIEW, we should not silently choose:
FAILED
or:
SUCCEEDED
Treat unknown status as:
unexpected provider response
until the contract is understood.
This makes provider-contract changes visible.
Fail Closed for Payment Success
For financial effects, success should require positive evidence.
Conceptually:
known confirmed success
→ SUCCEEDED
Every other case must be deliberately classified.
Do not use:
anything not explicitly failed
→ success
That is unsafe.
Order State During Failure
Our canonical Order lifecycle remains:
UNPAID
PAID
CANCELLED
For:
confirmed payment rejection
Order remains:
UNPAID
For:
provider unavailable
Order remains:
UNPAID
For:
timeout
Order also remains:
UNPAID
unless later reconciliation confirms success and the workflow safely applies it.
Why Not Add PAYMENT_FAILED?
Suppose Customer attempts payment three times:
Attempt 1
→ rejected
Attempt 2
→ provider unavailable
Attempt 3
→ timeout
The Order lifecycle may still simply be:
UNPAID
The payment attempts had different outcomes.
That does not mean Order itself moved through:
PAYMENT_FAILED
PAYMENT_ERROR
PAYMENT_TIMEOUT
Those are integration/attempt concepts.
Why Not Add PAYMENT_PENDING?
A timeout may tempt us to change Order to:
PAYMENT_PENDING
But our current Domain requirements do not define such an Order state.
Adding it would affect:
cancellation
payment eligibility
API representation
database CHECK constraint
tests
history semantics
We do not expand the state machine merely to store uncertainty from an integration.
If persistent payment-attempt state becomes necessary, it should be modeled separately.
Payment Attempt State May Need Separate Persistence
The course has already recognized that future payment integration may require:
attempt started
provider success
provider failure
outcome unknown
provider reference
These are not necessarily Order fields.
A later workflow may need a dedicated persisted payment-attempt concept.
But we do not invent the schema yet because provider semantics are still unresolved.
Failure Before Side Effect vs Failure After Side Effect
A powerful mental model is to ask:
Could the provider already
have performed the action?
If the answer is definitely:
no
retry may eventually be easier.
If the answer is:
maybe
retry becomes dangerous.
This is why failure location matters.
Failure Before Request Is Sent
Example:
request serialization fails locally
No provider call occurred.
That is clearly:
local technical failure
Retrying after fixing the bug/configuration would not duplicate an already-created payment because nothing was sent.
But that still does not justify automatic retry.
It simply gives us stronger knowledge.
Failure During Connection Setup
Suppose connection cannot be established.
Depending on the exact client/network failure, we may have relatively high confidence that the provider did not process the request.
But automatic payment retry still requires an explicit policy.
Why?
Because payment correctness should be based on:
documented provider guarantees
not guesses about network internals.
Failure After Request Transmission
This is the dangerous zone.
Conceptually:
request bytes sent
↓
provider may process
↓
response missing
Now:
outcome unknown
unless the provider offers a way to:
query the payment
reuse an idempotency key
reconcile by reference
This leads directly to our next lesson.
Why Blind Retry Is Dangerous
Suppose:
attempt 1
→ provider succeeds
→ response lost
Our backend sees:
timeout
Then immediately retries with a fresh payment request.
If provider treats it as a new operation:
attempt 2
→ provider succeeds again
Customer may be charged twice.
Therefore:
Retries can create additional external side effects.
Retry is not merely a reliability feature.
For payments, retry is part of correctness design.
Retry Is Different From Local Database Retry
A PostgreSQL deadlock retry can repeat a transaction because the failed transaction did not commit.
External payment retry does not necessarily have that guarantee.
The remote action may have committed even when our local call appears to fail.
This is why generic retry advice cannot be applied blindly to external financial operations.
Do Not Add @Retryable Casually
Bad:
@Retryable
public PaymentResult pay(
PaymentRequest request
) {
...
}
Without answering:
Is the operation idempotent?
What key identifies the attempt?
What failures are safe to retry?
Does provider deduplicate?
Can outcome be queried?
an automatic retry annotation can cause duplicate payments.
Framework convenience does not replace protocol design.
Do Not Retry Every Exception
Likewise:
for (int attempt = 0;
attempt < 3;
attempt++) {
try {
return paymentService.pay(
request
);
} catch (Exception exception) {
// retry everything
}
}
could retry:
invalid credentials
invalid request schema
unknown provider response
already-completed payment
timeout after successful charge
Many of these should not be retried blindly.
Failure Classification Comes Before Retry Policy
The correct order is:
understand failure
↓
understand provider guarantees
↓
determine retry safety
↓
implement retry policy
Not:
add retry
↓
hope errors disappear
Customer-Facing Behavior
Suppose provider explicitly rejects the payment.
Later API behavior may report a known payment failure through the application's stable error contract.
But suppose the payment provider times out.
The API should not tell the Customer:
Your payment definitely failed.
if the backend cannot prove that.
That can encourage the Customer to retry and create duplicate-charge risk.
Do Not Expose Raw Provider Failure
At the same time, the Customer should not receive:
java.net.SocketTimeoutException
HTTP 502
provider host
provider internal code
Public API errors must remain application-oriented and safe.
The final API mapping will be designed when the Pay Order API is implemented.
This lesson establishes the semantic distinction required for that mapping.
Internal Error Detail Must Be Rich Enough
Internally, operators may need:
OrderId
request/correlation ID
failure category
provider response category
whether timeout occurred
provider reference if known
to investigate the incident.
But never log:
provider secret
Authorization header
sensitive payment credentials
Observability and security must coexist.
Failure Logging Severity
A confirmed payment rejection is often:
normal business behavior
It may not deserve:
ERROR stack trace
for every occurrence.
A provider outage or unexpected response is:
operationally significant
and should eventually be observable accordingly.
Detailed logging and metrics come later in the Production Observability module.
Timeouts Must Be Bounded
External calls must not wait indefinitely.
Without a timeout:
request thread
or another execution resource may remain occupied while:
provider never responds
Under load this can exhaust:
threads
connections
request capacity
So provider calls need explicit bounded waiting.
Timeout Value Is Configuration
The actual timeout value belongs to:
provider configuration
not:
Thread.sleep(...)
or an unexplained constant buried inside PayOrderUseCase.
Example concept:
payment:
provider:
timeout: ...
The actual value must come from an engineering decision and provider expectations.
We do not invent one in this lesson.
Connect Timeout vs Response Timeout
HTTP clients may distinguish:
connection timeout
from:
response/read timeout
These can have different implications.
For example:
connection timeout
may suggest the request never reached the provider.
A response timeout may mean:
provider received and processed
the request
but response was not received
The exact guarantees depend on the client and network.
Do not build payment correctness on assumptions that are not documented.
Provider Timeout and Order Cancellation
There is another important interaction.
Suppose:
Order = UNPAID
Customer starts payment.
Provider call times out.
The local Order still appears:
UNPAID
Could Customer immediately cancel it?
If the provider may actually have charged the Customer, cancellation could restore Inventory while the external payment later proves successful.
This is one reason payment uncertainty eventually requires workflow-level consistency design.
We do not solve that merely by leaving Order UNPAID.
This Connects to Pay vs Cancel
Earlier we identified:
Pay
vs
Cancel
as a cross-system race.
Timeout makes it even more important.
Conceptually:
payment request
→ outcome uncertain
while:
cancellation request
→ local Order still UNPAID
The payment workflow must eventually have a way to prevent contradictory committed state.
That is addressed later in this module.
Do Not Hold a Database Lock During Remote Wait
One tempting solution is:
lock Order
↓
call provider
↓
wait
↓
keep lock until response
This is not our desired architecture.
Remote calls may take:
hundreds of milliseconds
seconds
or timeout
Holding a database row lock across that network wait increases:
contention
transaction duration
connection usage
deadlock/timeout risk
External-service consistency requires a different strategy.
Local and Remote Failure Boundaries
Think of the Pay Order workflow as crossing two worlds:
Local world
-----------
PostgreSQL
Order state
transaction
Remote world
------------
Payment Provider
network
provider persistence
A local rollback cannot reverse remote success.
A remote rejection does not automatically roll back unrelated committed local state.
We need explicit coordination.
@Transactional Cannot Solve Timeout Ambiguity
Suppose:
@Transactional
public void payOrder(...) {
PaymentResult result =
paymentService.pay(...);
...
}
If the remote call times out:
PostgreSQL transaction
can roll back local changes.
But it cannot answer:
Did the provider already charge?
That question lives outside the transaction.
Testing Failure Semantics
The provider integration should be tested against controlled failure scenarios.
Using a stub server, test:
confirmed success
confirmed rejection
provider 5xx
connection failure
timeout
malformed response
unknown provider status
Each should produce the intended application/integration behavior.
Test — Confirmed Rejection
Stub a real provider rejection response.
Expected:
PaymentResult.FAILED
No technical exception.
This proves:
business failure
is recognized correctly.
Test — Provider 5xx
Stub:
500 / 503
Expected:
technical PaymentProviderException
not:
PaymentResult.FAILED
unless the actual provider contract explicitly defines otherwise.
Test — Timeout
Configure the provider stub to delay past the configured timeout.
Expected:
PaymentProviderTimeoutException
or equivalent explicit timeout classification.
Specifically verify:
timeout
does not become SUCCEEDED
and:
timeout
does not become confirmed FAILED
Test — Malformed Provider Response
Provider returns:
invalid / incomplete response
Expected:
integration failure
not business rejection.
Test — Unknown Status
Provider returns syntactically valid response with unsupported business status.
Expected:
unexpected provider response
This prevents unknown protocol changes from silently becoming incorrect payment decisions.
Test — Provider Failure Does Not Mutate Order
At the future PayOrderUseCase level:
Given:
Order = UNPAID
and:
PaymentService
→ technical failure
then:
Order remains UNPAID
No markPaid().
The workflow must not convert infrastructure failure into lifecycle success.
Test — Confirmed Rejection Does Not Mutate Order
Likewise:
PaymentResult.FAILED
must leave:
Order = UNPAID
This satisfies the original Payment story:
A failed payment must not mark the Order as paid.
Failure Matrix
A useful conceptual matrix is:
| Situation | What we know | Application meaning |
|---|---|---|
| Provider confirms success | Payment succeeded | SUCCEEDED |
| Provider confirms rejection | Payment did not succeed | FAILED |
| Provider unreachable | No normal provider result | Technical failure |
| Provider 5xx | Provider interaction failed | Technical failure |
| Malformed response | Outcome cannot be interpreted | Technical/uncertain failure |
| Unknown status | Outcome not safely understood | Technical/uncertain failure |
| Timeout | Response not received in time; external outcome may be unknown | Explicit timeout / uncertain outcome |
The exact provider contract may refine individual rows.
The important principle is that they are not one category.
Avoid Overfitting to HTTP
External-service failure modeling should not become:
HTTP 200
HTTP 400
HTTP 500
as application concepts.
HTTP is provider transport.
Our application should reason in terms such as:
confirmed payment success
confirmed payment rejection
provider unavailable
outcome uncertain
Provider adapter translates HTTP into those meanings.
Avoid Generic ExternalServiceException
A single:
ExternalServiceException
for every integration in the application may become too vague.
Payment timeout semantics are particularly important.
A payment-specific failure boundary such as:
PaymentProviderException
is clearer.
We do not need a generic framework before multiple integrations prove the need.
Avoid Putting Failure State in Order
Do not add:
private String lastPaymentError;
or:
private boolean providerUnavailable;
to Order merely to store integration failures.
Order owns business lifecycle.
Operational/integration state belongs elsewhere if it later needs persistence.
Do Not Restore Inventory on Payment Failure
Inventory was consumed during successful Order creation.
A failed payment attempt does not cancel the Order.
Therefore:
payment rejection
must not call:
inventoryRepository.restore(...);
Inventory restoration belongs only to successful Order cancellation.
Do Not Recreate the Order
Likewise, a payment failure does not require:
delete Order
recreate Order
recalculate total
The Order remains the same historical purchase intent:
same OrderId
same CustomerId
same OrderItems
same unitPriceCents
same totalCents
status UNPAID
Only payment did not complete.
No Automatic Cancellation Timeout
We also do not say:
payment request timed out
→ cancel Order
because timeout may mean provider success.
Automatically cancelling would risk:
Inventory restored
+
Customer actually charged
which is a serious inconsistency.
Error Handling Must Preserve Truth
The overarching rule from our earlier error lesson applies here too:
Never convert a technical inability to determine the outcome into an expected business result just because the business result is easier to handle.
For payments:
"I know the provider rejected it"
is very different from:
"I do not know what the provider did."
The system must preserve that difference.
Observability Implications
Later, production observability should distinguish:
payment rejected
from:
provider timeout
from:
provider unavailable
from:
unexpected provider response
Otherwise an outage could look like:
Customers suddenly have more declined cards
which would be operationally misleading.
Metrics Later
Possible future metrics may include:
payment attempts
confirmed successes
confirmed rejections
provider failures
provider timeouts
unknown outcomes
provider latency
But implementation belongs to Module 11 — Production Observability.
We only ensure the integration preserves categories that can later be observed.
Alerting Later
Expected Customer payment rejection probably should not trigger:
incident alert
every time.
A sudden provider timeout rate might.
That distinction is only possible if our integration does not flatten both into:
FAILED
Public API Later
When we expose:
POST /api/v1/orders/{orderId}/pay
the API will need to decide how to represent:
confirmed payment rejection
versus:
temporary provider failure
versus:
uncertain outcome
We deliberately do not finalize those public codes here because the full workflow and provider contract are not finished.
Why Not Invent PAYMENT_TIMEOUT API Code Yet?
A stable public API code becomes a contract.
If later the provider supports:
safe status lookup
a timeout may be resolved internally before the Customer sees an error.
If the provider uses an asynchronous model, timeout semantics may differ again.
So we should not create public error codes before the workflow is designed.
Internal distinctions come first.
External Failure Review Checklist
For each provider failure, ask:
Did we receive a provider response?
Did the provider explicitly confirm
payment success?
Did the provider explicitly confirm
payment rejection?
Could the request have reached
the provider?
Could the provider already have
performed the payment?
Can the payment be queried later?
Can the same request safely be retried?
Does the provider support
an idempotency key?
Do we have a stable provider reference?
Should this be an application result
or a system failure?
What should remain true
about the Order?
If these questions cannot be answered, do not pretend the payment outcome is known.
Common Mistakes
Avoid:
catch all exceptions
→ PaymentResult.FAILED
Avoid:
timeout
→ payment failed
Avoid:
timeout
→ payment succeeded
Avoid:
provider 500
→ declined payment
Avoid:
unknown response status
→ assume failure
Avoid:
HTTP 200
→ always success
Avoid automatic retries without provider idempotency guarantees.
Avoid changing OrderStatus to:
PAYMENT_TIMEOUT
PAYMENT_FAILED
Avoid cancelling the Order automatically after payment failure.
Avoid restoring Inventory after payment failure.
Avoid holding a database lock while waiting on a remote provider.
Avoid exposing provider technical details directly through the Customer API.
Failure Model
Our current external-payment model can be summarized as:
PaymentService
↓
External Provider
Possible normal outcomes:
confirmed success
→ SUCCEEDED
confirmed rejection
→ FAILED
Possible integration failures:
provider unreachable
provider error
invalid response
unknown response
timeout
These do not become normal payment rejection automatically.
Order Lifecycle Model Remains Unchanged
Regardless of integration detail:
UNPAID
↓ confirmed successful payment
PAID
and:
UNPAID
↓ successful cancellation
CANCELLED
There is still no:
PAYMENT_FAILED
PAYMENT_TIMEOUT
PROVIDER_ERROR
in OrderStatus.
Engineering Principle
The core principle is:
An external-service error should be modeled according to what the application knows about the external side effect, not merely according to the local exception that occurred.
Another:
Timeout is an absence of timely confirmation, not proof of business failure. If the provider may already have performed the payment, the outcome is uncertain until provider guarantees or reconciliation establish otherwise.
And:
Failure classification must come before retry policy. A retry can create a second external side effect, so retry safety must be derived from provider idempotency and reference semantics rather than generic resilience patterns.
Summary
In this lesson, we established that:
- External-service failures have different semantics from local database failures.
- The important question is what we know about the external side effect.
- Confirmed provider success maps to payment success.
- Confirmed provider rejection maps to payment failure.
- Confirmed payment failure leaves the Order
UNPAID. - Payment failure does not cancel the Order.
- Technical provider failures are not payment rejection.
- Provider outages must not be translated into
PaymentResult.FAILED. - DNS, connection, TLS, provider 5xx, invalid response, and unexpected response are integration failures.
- Provider 4xx responses require provider-specific interpretation.
- Wrong provider credentials are a system/configuration failure, not Customer payment failure.
- Invalid provider requests can indicate an integration defect.
- HTTP 2xx does not automatically mean payment success.
- HTTP error does not automatically mean payment rejection.
- Unknown provider statuses must fail safely.
- Payment success should require explicit confirmation.
- Timeouts are particularly dangerous because the provider may already have completed the payment.
- Timeout does not prove success.
- Timeout does not prove failure.
- Order remains
UNPAIDuntil confirmed success can safely be applied. - We do not add payment-attempt states to
OrderStatus. - Persistent payment-attempt state, if needed, should be modeled separately.
- A local PostgreSQL rollback cannot reverse remote payment success.
@Transactionalcannot resolve external outcome ambiguity.- Blind retry after timeout can charge the Customer twice.
- Generic retry annotations should not be added before idempotency semantics are understood.
- Failure classification comes before retry policy.
- Payment failures do not restore Inventory.
- Payment failures do not recreate or delete the Order.
- Payment timeout must not automatically cancel the Order.
- Pay-vs-Cancel becomes more difficult when payment outcome is uncertain.
- We must not hold a database row lock while waiting on a remote provider.
- Integration tests should cover success, rejection, provider error, timeout, malformed response, and unknown status.
- Observability later must distinguish business rejection from provider/system failure.
- We do not finalize public payment-error codes before the complete workflow and provider contract are understood.
Next lesson:
Retry Safety and Idempotency
There we will answer the next critical question:
If an external payment call
fails or times out,
when is it safe to try again?
We will distinguish:
HTTP retry
business retry
idempotent request
duplicate provider execution
duplicate local result application
and establish why Order.markPaid() rejecting a second transition is not enough to prevent a Customer from being charged twice.
