From ecedfdcbe0232bc0305818d3604aca837e578f1d Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 1 Jul 2026 18:42:18 -0300 Subject: [PATCH 1/2] feat(pay): forward provider error identity and mark failed spans ERROR Payment handlers now forward the provider/on-chain error code+message through pay-platform's PlatformError + PIPE_APIError pipeline instead of swallowing it behind a catch-all 500/502 (#8), mapping upstream status to the allowed error-status set. Each failed payment marks its span ERROR with recordException, preserving the cause chain (#9). --- src/http/v1/pay/instant-execute.ts | 50 ++++++--- src/http/v1/pay/instant-prepare.ts | 18 ++- src/http/v1/pay/instant-submit.ts | 29 +++-- src/http/v1/pay/pay.errors.ts | 172 +++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 32 deletions(-) create mode 100644 src/http/v1/pay/pay.errors.ts diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index 93f7540..1249d53 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -24,7 +24,13 @@ import { STELLAR_RPC_URL, } from "@/config/env.ts"; import type { Logger } from "@/utils/logger/index.ts"; -import { withSpan } from "@/core/tracing.ts"; +import { SpanStatusCode, withSpan } from "@/core/tracing.ts"; +import { PlatformError } from "@/error/index.ts"; +import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; +import { + bundleSettlementFailed, + providerBundleRejected, +} from "@/http/v1/pay/pay.errors.ts"; const councilRepo = new CouncilRepository(drizzleClient); const channelRepo = new CouncilChannelRepository(drizzleClient); @@ -315,15 +321,7 @@ export function handleExecuteInstant( const errBody = await bundleRes.text().catch(() => ""); log.debug("status", bundleRes.status); log.debug("body", errBody); - log.error( - new Error(`HTTP ${bundleRes.status}`), - "provider bundle submission failed", - ); - ctx.response.status = Status.BadGateway; - ctx.response.body = { - message: "Payment processing failed — provider rejected the bundle", - }; - return; + throw providerBundleRejected(bundleRes.status, errBody); } const bundleData = await bundleRes.json().catch(() => ({})); @@ -350,9 +348,10 @@ export function handleExecuteInstant( }); if (!pollRes.ok) { if (pollRes.status === 429) continue; - throw new Error( - `Bundle poll failed: ${pollRes.status} ${await pollRes.text()}`, - ); + const errBody = await pollRes.text().catch(() => ""); + log.debug("pollStatus", pollRes.status); + log.debug("pollBody", errBody); + throw providerBundleRejected(pollRes.status, errBody); } const pollData = await pollRes.json().catch(() => ({})); const bundleStatus = pollData?.data?.status; @@ -362,11 +361,18 @@ export function handleExecuteInstant( break; } if (bundleStatus === "FAILED" || bundleStatus === "EXPIRED") { - throw new Error(`Bundle ${bundleId} ${bundleStatus}`); + throw bundleSettlementFailed({ + bundleId, + outcome: bundleStatus, + failureDetail: pollData?.data?.failureDetail, + }); } } if (!settled) { - throw new Error(`Bundle ${bundleId} did not settle within 120 s`); + throw bundleSettlementFailed({ + bundleId, + outcome: "did not settle within 120 s", + }); } log.event("bundle settled on chain"); } @@ -394,8 +400,18 @@ export function handleExecuteInstant( }; } catch (error) { log.error(error, "failed to execute instant payment"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to process payment" }; + const platformError = PlatformError.fromUnknown(error, { + source: "@http/v1/pay/instant-execute", + details: "Failed to process the instant payment.", + }); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: platformError.message, + }); + span.recordException( + error instanceof Error ? error : new Error(String(error)), + ); + await PIPE_APIError(ctx, deps).run(platformError); } }); } diff --git a/src/http/v1/pay/instant-prepare.ts b/src/http/v1/pay/instant-prepare.ts index 9cbcb85..dea4aea 100644 --- a/src/http/v1/pay/instant-prepare.ts +++ b/src/http/v1/pay/instant-prepare.ts @@ -13,7 +13,9 @@ import { STELLAR_NETWORK_PASSPHRASE, } from "@/config/env.ts"; import type { Logger } from "@/utils/logger/index.ts"; -import { withSpan } from "@/core/tracing.ts"; +import { SpanStatusCode, withSpan } from "@/core/tracing.ts"; +import { PlatformError } from "@/error/index.ts"; +import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; const councilRepo = new CouncilRepository(drizzleClient); const channelRepo = new CouncilChannelRepository(drizzleClient); @@ -216,8 +218,18 @@ export function handlePrepareInstant( }; } catch (error) { log.error(error, "failed to prepare instant payment"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to prepare payment" }; + const platformError = PlatformError.fromUnknown(error, { + source: "@http/v1/pay/instant-prepare", + details: "Failed to prepare the instant payment.", + }); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: platformError.message, + }); + span.recordException( + error instanceof Error ? error : new Error(String(error)), + ); + await PIPE_APIError(ctx, deps).run(platformError); } }); } diff --git a/src/http/v1/pay/instant-submit.ts b/src/http/v1/pay/instant-submit.ts index e4e1e49..3e53350 100644 --- a/src/http/v1/pay/instant-submit.ts +++ b/src/http/v1/pay/instant-submit.ts @@ -7,7 +7,10 @@ import { TransactionRepository } from "@/persistence/drizzle/repository/transact import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; import { getProviderJwt } from "@/core/service/provider-auth.ts"; import type { Logger } from "@/utils/logger/index.ts"; -import { withSpan } from "@/core/tracing.ts"; +import { SpanStatusCode, withSpan } from "@/core/tracing.ts"; +import { PlatformError } from "@/error/index.ts"; +import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; +import { providerBundleRejected } from "@/http/v1/pay/pay.errors.ts"; const councilRepo = new CouncilRepository(drizzleClient); const channelRepo = new CouncilChannelRepository(drizzleClient); @@ -134,15 +137,7 @@ export function handleSubmitInstant( const errBody = await bundleRes.text().catch(() => ""); log.debug("status", bundleRes.status); log.debug("body", errBody); - log.error( - new Error(`HTTP ${bundleRes.status}`), - "provider-platform bundle submission failed", - ); - ctx.response.status = Status.BadGateway; - ctx.response.body = { - message: "Payment processing failed — provider rejected the bundle", - }; - return; + throw providerBundleRejected(bundleRes.status, errBody); } const bundleData = await bundleRes.json().catch(() => ({})); @@ -197,8 +192,18 @@ export function handleSubmitInstant( }; } catch (error) { log.error(error, "failed to submit instant payment"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to process payment" }; + const platformError = PlatformError.fromUnknown(error, { + source: "@http/v1/pay/instant-submit", + details: "Failed to process the instant payment.", + }); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: platformError.message, + }); + span.recordException( + error instanceof Error ? error : new Error(String(error)), + ); + await PIPE_APIError(ctx, deps).run(platformError); } }); } diff --git a/src/http/v1/pay/pay.errors.ts b/src/http/v1/pay/pay.errors.ts new file mode 100644 index 0000000..4b05dfe --- /dev/null +++ b/src/http/v1/pay/pay.errors.ts @@ -0,0 +1,172 @@ +import { PlatformError } from "@/error/index.ts"; +import type { APIErrorStatus } from "@/http/default-schemas.ts"; + +/** + * Error codes owned by the /pay/* payment slice. These are used only when the + * upstream provider-platform does not supply its own structured error `code` + * (in which case that provider/soroman code is forwarded verbatim instead). + */ +export enum PAY_ERROR_CODES { + PROVIDER_BUNDLE_REJECTED = "PAY_BUNDLE_001", + BUNDLE_SETTLEMENT_FAILED = "PAY_BUNDLE_002", +} + +const source = "@http/v1/pay"; + +/** + * Statuses accepted by `errorStatusSchema` (default-schemas.ts). Any upstream + * status outside this set must be mapped before it reaches the wire. + */ +const ALLOWED_STATUSES: readonly number[] = [400, 401, 403, 404, 409, 429]; + +/** + * Maps an upstream provider-platform HTTP status onto a status allowed by + * pay-platform's `errorStatusSchema`. + * - An allowed 4xx passes through unchanged. + * - Any other 4xx (a provider-side rejection) collapses to 409 Conflict. + * - A 5xx / unreachable upstream collapses to 500. + */ +export function mapProviderStatus(providerStatus: number): APIErrorStatus { + if (ALLOWED_STATUSES.includes(providerStatus)) { + return providerStatus as APIErrorStatus; + } + if (providerStatus >= 400 && providerStatus < 500) return 409; + return 500; +} + +type ProviderErrorBody = { + code?: unknown; + status?: unknown; + message?: unknown; + details?: unknown; +}; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +/** + * Parses a provider-platform error response body. Provider `PlatformError`s + * serialize as `{ code, status, message, details }`. Returns `undefined` when + * the body is missing or is not a JSON object. + */ +export function parseProviderError( + raw: string, +): ProviderErrorBody | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + return parsed as ProviderErrorBody; + } + } catch { + // Not JSON — there is no structured identity to forward. + } + return undefined; +} + +/** + * A bundle submit/poll returned a non-2xx from provider-platform. Forwards the + * provider's own error `code` and safe `message`/`details` through + * pay-platform's structured error system. The raw upstream body is kept only on + * `baseError` (for logs/traces) and never reaches the wire. + */ +export class PROVIDER_BUNDLE_REJECTED extends PlatformError { + constructor(args: { + providerStatus: number; + providerCode?: string; + providerMessage?: string; + providerDetails?: string; + baseError?: Error | unknown; + }) { + const status = mapProviderStatus(args.providerStatus); + const message = args.providerMessage ?? + "The payment bundle was rejected by the provider."; + super({ + source, + code: args.providerCode ?? PAY_ERROR_CODES.PROVIDER_BUNDLE_REJECTED, + message, + details: args.providerDetails, + baseError: args.baseError, + api: { + status, + message, + details: args.providerDetails, + }, + }); + } +} + +/** + * Builds a {@link PROVIDER_BUNDLE_REJECTED} from a raw upstream response, + * preserving the cause chain on `baseError`. + */ +export function providerBundleRejected( + providerStatus: number, + rawBody: string, +): PROVIDER_BUNDLE_REJECTED { + const parsed = parseProviderError(rawBody); + return new PROVIDER_BUNDLE_REJECTED({ + providerStatus, + providerCode: asString(parsed?.code), + providerMessage: asString(parsed?.message), + providerDetails: asString(parsed?.details), + baseError: new Error( + `provider-platform responded ${providerStatus}`, + rawBody ? { cause: new Error(rawBody) } : undefined, + ), + }); +} + +/** + * An async bundle failed to settle on chain (poll returned FAILED/EXPIRED, or + * the settlement wait timed out). The on-chain identity lives in the bundle + * GET's `data.failureDetail = { code, source, message, name? }`. + */ +export class BUNDLE_SETTLEMENT_FAILED extends PlatformError { + constructor(args: { + bundleId: string; + outcome: string; + failureCode?: string; + failureMessage?: string; + baseError?: Error | unknown; + }) { + const message = args.failureMessage ?? + `The payment bundle did not settle (${args.outcome}).`; + super({ + source, + code: args.failureCode ?? PAY_ERROR_CODES.BUNDLE_SETTLEMENT_FAILED, + message, + baseError: args.baseError, + api: { + status: 409, + message, + }, + }); + } +} + +type FailureDetail = { + code?: unknown; + source?: unknown; + message?: unknown; + name?: unknown; +}; + +/** + * Builds a {@link BUNDLE_SETTLEMENT_FAILED} from a bundle GET's + * `data.failureDetail`, preserving the cause chain on `baseError`. + */ +export function bundleSettlementFailed(args: { + bundleId: string; + outcome: string; + failureDetail?: FailureDetail; +}): BUNDLE_SETTLEMENT_FAILED { + const { bundleId, outcome, failureDetail } = args; + return new BUNDLE_SETTLEMENT_FAILED({ + bundleId, + outcome, + failureCode: asString(failureDetail?.code), + failureMessage: asString(failureDetail?.message), + baseError: new Error(`bundle ${bundleId} ${outcome}`), + }); +} From 519bdc7e5f2fc04f0b2be252dfa2ec925b7b3377 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 2 Jul 2026 16:45:58 -0300 Subject: [PATCH 2/2] chore: bump version to 0.5.26 --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index ef39e65..3cbd52a 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/pay-platform", - "version": "0.5.25", + "version": "0.5.26", "license": "MIT", "exports": "./src/main.ts", "tasks": {