Working with External Services

Handling Timeouts and External Failures

ReadingPreview

You are viewing a free preview lesson.

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:

SituationWhat we knowApplication meaning
Provider confirms successPayment succeededSUCCEEDED
Provider confirms rejectionPayment did not succeedFAILED
Provider unreachableNo normal provider resultTechnical failure
Provider 5xxProvider interaction failedTechnical failure
Malformed responseOutcome cannot be interpretedTechnical/uncertain failure
Unknown statusOutcome not safely understoodTechnical/uncertain failure
TimeoutResponse not received in time; external outcome may be unknownExplicit 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 UNPAID until 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.
  • @Transactional cannot 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.