Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions backend/src/integrations/gateway-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
BaseGatewayAdapter,
type GatewayConfig,
type PaymentRequest,
type PaymentResponse,
} from './gateway-adapter';

const config: GatewayConfig = {
gatewayType: 'stripe',
enabled: true,
priority: 1,
maxRetries: 0,
timeoutMs: 1_000,
healthCheckIntervalMs: 60_000,
circuitBreakerThreshold: 3,
credentials: {},
};

const request: PaymentRequest = {
amount: '12.50',
currency: 'USD',
recipient: 'acct_test',
idempotencyKey: 'test-payment-1',
};

class TestGateway extends BaseGatewayAdapter {
nextResponse: PaymentResponse = {
success: true,
transactionId: 'txn_test',
status: 'completed',
};
shouldThrow = false;

getType() {
return this.config.gatewayType;
}

protected async executePayment(): Promise<PaymentResponse> {
if (this.shouldThrow) throw new Error('provider unavailable');
return this.nextResponse;
}

protected async performHealthCheck(): Promise<void> {}

forceHalfOpen() {
// Simulate the state after the circuit-breaker cooldown has elapsed.
this.circuitState = 'half_open' as typeof this.circuitState;
}
}

describe('BaseGatewayAdapter circuit-breaker payment outcomes', () => {
it('records a resolved but failed payment as a failure', async () => {
const gateway = new TestGateway(config);
gateway.nextResponse = {
success: false,
transactionId: 'txn_failed',
status: 'failed',
errorCode: 'DECLINED',
};

const response = await gateway.processPayment(request);

expect(response.status).toBe('failed');
expect(gateway.getHealthStatus().consecutiveFailures).toBe(1);
expect(gateway.getHealthStatus().errorRate).toBe(1);
expect(gateway.getHealthStatus().successRate).toBe(0);
});

it('reopens the circuit immediately when a half-open probe throws', async () => {
const gateway = new TestGateway(config);
gateway.forceHalfOpen();
gateway.shouldThrow = true;

await expect(gateway.processPayment(request)).rejects.toThrow('provider unavailable');
await expect(gateway.processPayment(request)).rejects.toThrow('circuit is open');
});
});
8 changes: 6 additions & 2 deletions backend/src/integrations/gateway-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,12 @@ export abstract class BaseGatewayAdapter implements IGatewayAdapter {
const processingTime = Date.now() - startTime;
this.recordFailure(processingTime);

// Check if should open circuit
if (this.health.consecutiveFailures >= this.config.circuitBreakerThreshold) {
// A failed probe in half-open state must reopen the circuit immediately;
// otherwise a single probe failure can leave the gateway half-open.
if (
this.health.consecutiveFailures >= this.config.circuitBreakerThreshold ||
this.circuitState === CircuitState.HalfOpen
) {
this.openCircuit();
}

Expand Down
37 changes: 37 additions & 0 deletions docs/GATEWAY_FAILURE_SEMANTICS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Gateway failure and circuit-breaker semantics

This note documents the behavior expected from the shared gateway adapter.

## Payment outcomes are not transport outcomes

A provider call can resolve at the HTTP/SDK layer while reporting a declined or
failed payment. The adapter must record a failure whenever `success` is false
or `status` is `failed`; it must not increase the success rate merely because
the provider call resolved. The original response is returned so callers retain
the provider's transaction identifier and error details.

## Circuit-breaker recovery

After the failure threshold is reached, the gateway circuit opens. Once the
cooldown expires, one request may act as a half-open probe. A successful probe
closes the circuit and resets consecutive failures. A failed response or thrown
error during that probe reopens the circuit immediately; it must not remain
half-open until another threshold is reached.

## Safe fallback and idempotency

Do not automatically replay a payment against another provider solely because
the response is a definitive decline/failure: the provider may already have
processed the request. Failover after ambiguous transport errors requires a
shared idempotency key or reconciliation that can establish the original
outcome. Keep the same payment identifier and idempotency key across safe
retries.

## Verification

From `backend/`, run:

```bash
npm test -- src/integrations/gateway-adapter.test.ts
npm run build
```
5 changes: 5 additions & 0 deletions docs/ITERATION_11.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ services and tests so future changes remain reviewable.
When adding a provider, cover quote creation, timeout behavior, provider
failure mapping, and idempotent retry behavior in the provider test suite.

## Gateway failure semantics

- Failure metrics, half-open circuit recovery, and safe failover guidance:
[`GATEWAY_FAILURE_SEMANTICS.md`](GATEWAY_FAILURE_SEMANTICS.md)

## Payment correctness

- Payment reconciliation and mismatch handling:
Expand Down
Loading