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
3 changes: 2 additions & 1 deletion docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ retry behavior during network failures or race conditions. Currently supported:

- `POST /api/v1/postage/` - Postage submission
- `POST /api/v1/postage/:messageId/settle` - Postage settlement
- `POST /api/v1/postage/:messageId/refund` - Postage refund

See [SETTLEMENT_IDEMPOTENCY.md](./SETTLEMENT_IDEMPOTENCY.md) for detailed documentation on
See [SETTLEMENT_IDEMPOTENCY.md](./SETTLEMENT_IDEMPOTENCY.md) and [REFUND_IDEMPOTENCY.md](./REFUND_IDEMPOTENCY.md) for detailed documentation on
idempotency semantics, retry scenarios, and client best practices.

## Input Validation
Expand Down
246 changes: 246 additions & 0 deletions docs/api/REFUND_IDEMPOTENCY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
# Postage Refund Idempotency

## Overview

The postage refund endpoint (`POST /api/v1/postage/:messageId/refund`) implements idempotency to ensure safe retry behavior during network failures, race conditions, or other transient errors.

## Problem Statement

Refund operations involve critical state transitions in the escrow system:

- Moving postage from `pending` to `refunded` returns escrow funds to the sender
- Network failures can cause clients to retry refund requests
- Without idempotency, retries could result in conflicting responses or ambiguous system state
- Concurrent settlement and refund attempts need deterministic conflict outcomes (settlement vs. refund)

## Solution

### Idempotency Key Header

Clients can include an optional `X-Idempotency-Key` header with refund requests:

```http
POST /api/v1/postage/abc123.../refund
Authorization: Bearer <recipient-token>
X-Idempotency-Key: unique-refund-request-id
```

### Key Properties

- **Actor-scoped**: Keys are scoped per recipient, preventing cross-actor collisions
- **SHA-256 hashed**: Raw keys are hashed to protect against key leakage in logs
- **Success replay**: Successful refunds (200) are cached and replayed
- **Error replay**: Terminal-state errors (409 conflict) are cached and replayed
- **Transient errors**: Non-terminal errors (500, network failures) are NOT cached, allowing retry

### Request Flow

```
┌─────────────┐
│ Client │
└──────┬──────┘
│ POST /refund (with idempotency key)
┌─────────────────────────────────────────┐
│ Check idempotency cache │
│ - Hash actor + key │
│ - Look up previous response │
└──────┬──────────────────────────────────┘
├─ Cache hit? ──► Return cached response (200 or 409)
│ + X-Idempotency-Replayed: true
└─ Cache miss
┌──────────────────────────────┐
│ Attempt refund │
│ - Load postage │
│ - Check status │
│ - Transition to "refunded" │
└──────┬───────────────────────┘
├─ Success (200)
│ - Cache response
│ - Return success
└─ Terminal error (409)
- Cache error
- Return error
```

## Retry Scenarios

### Scenario 1: Retry After Successful Refund

```http
# First request
POST /api/v1/postage/abc123.../refund
X-Idempotency-Key: req-001

Response: 200 OK
{
"data": { "status": "refunded", ... },
"meta": { "requestId": "..." }
}
```

Network failure occurs. Client retries:

```http
# Retry with same key
POST /api/v1/postage/abc123.../refund
X-Idempotency-Key: req-001

Response: 200 OK
X-Idempotency-Replayed: true
{
"data": { "status": "refunded", ... }, # Same response
"meta": { "requestId": "..." }
}
```

**Result**: No double-refunding. Same response returned. Client can safely process.

### Scenario 2: Retry After Terminal-State Error

Postage already refunded or settled by another process:

```http
# First request
POST /api/v1/postage/abc123.../refund
X-Idempotency-Key: req-002

Response: 409 Conflict
{
"error": {
"code": "conflict",
"message": "Postage has already been refunded. The escrow was previously returned to the sender.",
"details": {
"currentStatus": "refunded",
"attemptedStatus": "refunded",
"messageId": "abc123..."
}
}
}
```

Client retries:

```http
# Retry with same key
POST /api/v1/postage/abc123.../refund
X-Idempotency-Key: req-002

Response: 409 Conflict
X-Idempotency-Replayed: true
{
"error": { ... } # Same error
}
```

**Result**: Deterministic error response. Client knows refund already completed.

### Scenario 3: Settlement vs. Refund Race

```http
# Settle message A
POST /api/v1/postage/messageA.../settle

# Refund message A concurrently
POST /api/v1/postage/messageA.../refund
```

**Result**: Atomic compare-and-swap guarantees only one operation wins `pending` state transition. The losing operation receives a `409 conflict` detailing the current status (`settled` or `refunded`).

## Error Messages

The implementation provides detailed error messages for terminal states:

### Already Refunded

```json
{
"error": {
"code": "conflict",
"message": "Postage has already been refunded. The escrow was previously returned to the sender.",
"details": {
"currentStatus": "refunded",
"attemptedStatus": "refunded",
"messageId": "..."
}
}
}
```

### Already Settled

```json
{
"error": {
"code": "conflict",
"message": "Postage has already been settled. The escrow was previously released to the recipient.",
"details": {
"currentStatus": "settled",
"attemptedStatus": "refunded",
"messageId": "..."
}
}
}
```

## Security Considerations

### Actor Isolation

Idempotency keys are scoped per recipient:

```typescript
hashIdempotencyKey(actor: string, rawKey: string): string {
return createHash("sha256")
.update(`${actor}:${rawKey}`)
.digest("hex");
}
```

This ensures:

- Recipient A cannot replay responses meant for Recipient B
- Same key used by different recipients produces different cache entries
- No cross-actor information leakage

### Key Hashing

Raw idempotency keys are hashed before storage:

- Prevents key leakage in logs or database exports
- Provides consistent 64-character hex identifiers
- SHA-256 is computationally secure for this use case

## Implementation Details

### Code Location

- **Endpoint**: `src/routes/api/v1/postage/$messageId/refund.ts`
- **Service Logic**: `src/server/api/postage-service.ts` (`resolvePostage`)
- **Idempotency Logic**: `src/server/api/idempotency-service.ts`
- **Tests**: `tests/unit/api/postage-refund-idempotency.test.ts`

### Test Coverage

The test suite covers:

- ✅ Deterministic terminal states (refunded/settled)
- ✅ Success response replay
- ✅ Terminal error response replay (409)
- ✅ Actor isolation (different recipients don't collide)
- ✅ Network failure retry scenarios
- ✅ Settlement vs. refund concurrency races
- ✅ Data integrity across retries
- ✅ Missing postage error handling

## Related Endpoints

- `POST /api/v1/postage/` (postage submission)
- `POST /api/v1/postage/:messageId/settle` (postage settlement)
101 changes: 99 additions & 2 deletions src/routes/api/v1/postage/$messageId/refund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,38 @@ import { getApiContext } from "@/server/api/context";
import { hash32Schema } from "@/server/api/domain";
import { getPostage, resolvePostage } from "@/server/api/postage-service";
import { apiSuccess, handleApiRequest } from "@/server/api/response";
import { acquireIdempotency, recordIdempotency } from "@/server/api/idempotency-service";
import { ApiError } from "@/server/api/errors";

/**
* POST /api/v1/postage/:messageId/refund
*
* Refunds postage for a message, marking it as refunded and returning escrow to the sender.
*
* ## Idempotency
*
* This endpoint supports idempotent refund via the optional `X-Idempotency-Key` header.
* When provided:
* - Multiple refund requests with the same key will return the same response
* - The first successful refund is recorded and replayed on subsequent requests
* - If refund fails (e.g., already settled or refunded), the error is cached and replayed
* - Idempotency keys are scoped per recipient to prevent cross-actor collisions
*
* ## Retry Safety
*
* Refund operations are safe to retry:
* - If postage is already refunded, returns 409 with explanation
* - If postage is settled, returns 409 with current state
* - Network failures do not cause double-refunding
* - Terminal states (settled/refunded) are deterministic
*
* @example
* ```
* POST /api/v1/postage/abc123.../refund
* X-Idempotency-Key: unique-refund-request-id
* Authorization: Bearer <recipient-token>
* ```
*/
export const Route = createFileRoute("/api/v1/postage/$messageId/refund")({
server: {
handlers: {
Expand All @@ -19,8 +50,74 @@ export const Route = createFileRoute("/api/v1/postage/$messageId/refund")({
const messageId = hash32Schema.parse(params.messageId);
const current = await getPostage(repository, messageId);
requireActorMatches(context, current.recipient);
const postage = await resolvePostage(context, messageId, "refunded");
return apiSuccess(request, postage);

// Check for idempotency key to enable safe retries
const rawIdempotencyKey = request.headers.get("x-idempotency-key");
if (rawIdempotencyKey) {
const result = await acquireIdempotency(
repository,
current.recipient,
rawIdempotencyKey,
);

if (result.status === "in_progress") {
throw new ApiError(409, "conflict", "Request is already in progress");
}

if (result.status === "completed") {
// Replay the previous response (success or failure)
return apiSuccess(request, result.record.body, {
status: result.record.status,
headers: { "x-idempotency-replayed": "true" },
});
}
}

try {
const postage = await resolvePostage(context, messageId, "refunded");

// Record successful refund for idempotent replay
if (rawIdempotencyKey) {
await recordIdempotency(
repository,
current.recipient,
rawIdempotencyKey,
200,
postage,
);
}

return apiSuccess(request, postage);
} catch (error) {
// Record terminal-state errors for idempotent replay
// This ensures retry-after-failure returns the same error
if (
rawIdempotencyKey &&
error &&
typeof error === "object" &&
"status" in error &&
"code" in error &&
"message" in error
) {
const apiError = error as {
status: number;
code: string;
message: string;
details?: unknown;
};
// Only cache terminal-state errors (409 conflict), not transient failures
if (apiError.status === 409) {
await recordIdempotency(repository, current.recipient, rawIdempotencyKey, 409, {
error: {
code: apiError.code,
message: apiError.message,
details: apiError.details,
},
});
}
}
throw error;
}
}),
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/routes/api/v1/postage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Clients should branch on `error.code`, not on `message` text. Every response als

- **Retryable:** `too_many_requests` (429) — wait `details.retryAfterSeconds`, then retry the identical request. `internal_error` (500) — retry with exponential backoff.
- **Non-retryable:** 400, 401, 403, 404, 409, 413, 415, and 422. Retrying the same request unchanged will fail again; fix the request first.
- **Idempotent retries:** on `POST /api/v1/postage/`, send an `x-idempotency-key` header. A replay returns the stored `201` body with `x-idempotency-replayed: true` instead of a `409 conflict`, so automatic retries stay safe.
- **Idempotent retries:** on `POST /api/v1/postage/`, `POST /api/v1/postage/:messageId/settle`, and `POST /api/v1/postage/:messageId/refund`, send an `x-idempotency-key` header. A replay returns the stored body with `x-idempotency-replayed: true` instead of a `409 conflict`, so automatic retries stay safe.

### Errors by endpoint

Expand Down
Loading
Loading