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
2 changes: 1 addition & 1 deletion apps/facilitator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ unlocks one response.
| `settle_exact_stellar_payer_busy` | — | another payment from this payer in this asset is settling; nothing was recorded for this one | send the same payment again |
| `settle_exact_stellar_interrupted` | — | the facilitator restarted before anything was signed; nothing was or will be | repeat the same payment while `extra.retryable` (see below) |
| `settle_exact_stellar_shutting_down` | an earlier hash, if any | the facilitator was stopping; nothing was applied, and nothing more will be sent | repeat the same payment while `extra.retryable` (see below) |
| `settle_exact_stellar_rpc_unavailable` | an earlier hash, if any | no RPC endpoint answered (refused, failed or timed out) | repeat the same payment while `extra.retryable` (see below) |
| `settle_exact_stellar_rpc_unavailable` | an earlier hash, if any | no RPC endpoint answered (refused, failed or timed out); in the checks, a timeout only | repeat the same payment while `extra.retryable` (see below) |
| `settle_exact_stellar_no_channel_in_time` | — | every channel was busy until the deadline | repeat the same payment while `extra.retryable` (see below) |
| `settle_exact_stellar_no_channels` | — | every channel is out of the pool | repeat the same payment while `extra.retryable` (see below) |
| `settle_exact_stellar_lease_lost` | an earlier hash, if any | this process lost the channel lease before it sent; another process holds it, or none does | repeat the same payment while `extra.retryable` (see below) |
Expand Down
5 changes: 3 additions & 2 deletions apps/facilitator/src/http/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,9 @@ export function createApp({
});
res.json(response);
});
// Like `/verify`: any settle outcome is a 200 with an x402 body (AD-6). A thrown error, such as
// upstream verify failing or the store down before the claim, is a 500.
// Like `/verify`: any settle outcome is a 200 with an x402 body (AD-6), an RPC that gave the checks
// no answer included. A thrown error, such as upstream's `unexpected_verify_error` or the store
// down before the claim, is a 500.
app.post(
"/settle",
leased,
Expand Down
6 changes: 6 additions & 0 deletions apps/facilitator/src/settlement/settle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
import {
PAYER_AUTH_REASON,
UNSUPPORTED_CREDENTIAL_REASON,
UpstreamTimeoutError,
payerExpiration,
payerNonce,
type Check,
Expand Down Expand Up @@ -962,6 +963,11 @@ export function createSettler(deps: SettlerDeps): Settle & {
checked = await bounded(deps.check(payload, requirements), left());
} catch (error) {
if (error instanceof OutOfTimeError) return unclaimed();
// An RPC that gave no answer judged nothing, as `/verify`'s 503 says: nothing is claimed, so
// the same payment may be sent again as it is.
if (error instanceof UpstreamTimeoutError || error instanceof RpcUnavailableError) {
return { ...untouched, response: failure(SETTLE_REASONS.rpcUnavailable, undefined) };
}
throw error;
}
const { response: verdict, verified } = checked;
Expand Down
21 changes: 21 additions & 0 deletions apps/facilitator/test/settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
} from "../src/settlement/settle.js";
import {
PAYER_AUTH_REASON,
UpstreamTimeoutError,
createChecker,
payerNonce,
type Check,
Expand Down Expand Up @@ -1974,6 +1975,26 @@ describe("createSettler", () => {
expect(await store.get(key)).toBeUndefined();
});

it.each([
new UpstreamTimeoutError("upstream verify gave no answer within 10000 ms"),
new RpcUnavailableError("every RPC endpoint failed"),
])(
"answers rpc_unavailable without claiming when the checks' RPC gave no answer: $name",
async (error) => {
const check: Check = async () => {
throw error;
};
const { settle, store, submit } = setup(landing(), { check });
expect((await settle(payload(), requirements)).response).toMatchObject({
success: false,
errorReason: SETTLE_REASONS.rpcUnavailable,
});
expect(submit).not.toHaveBeenCalled();
// Nothing is recorded, so the same payment can be sent again as it is.
expect(await store.get(key)).toBeUndefined();
},
);

it("answers a timeout without claiming when the checks end just as the budget does", async () => {
let time = 0;
const check: Check = async (p, r) => {
Expand Down
Loading