diff --git a/docs/backend-performance-guidelines.md b/docs/backend-performance-guidelines.md index 3d659b22..68f70463 100644 --- a/docs/backend-performance-guidelines.md +++ b/docs/backend-performance-guidelines.md @@ -203,13 +203,39 @@ If a service consistently violates performance guidelines: Exceptions to these guidelines must be approved by the Architecture Review Board. +* **Request Process**: Submit a "Performance Exception Request" document detailing: + * Reason for exception (e.g., legacy system limitation, extremely complex computation). + * Impact analysis on system resources and user experience. + * Proposed mitigation (e.g., aggressive caching, async processing). + * Timeline for compliance or permanent waiver justification. + + +## 9. Soroban Read Retry Policy + +Read calls to Soroban RPC (`src/lib/backend/services/contracts.ts`) are wrapped in a bounded retry-with-exponential-backoff layer. Its purpose is to absorb short-lived RPC hiccups — rate limiting, gateway errors, timeouts — without surfacing them to callers, while staying strictly bounded so a struggling RPC endpoint can never stall a request. + +### What is retried + +Only **read-mode** contract calls, and only when they fail with a *transient* error. Transience is decided by `isRetryableContractError`, which reuses the classification produced by `normalizeContractError` so there is a single source of truth. Retried failures are HTTP 429, HTTP 503/504, timeout/deadline errors, and generic gateway-class failures (e.g. a dropped socket) — safe to retry because reads are idempotent. + +### What is never retried + +- **Write transactions — ever.** Re-submitting a signed transaction risks double execution. See "Write-retry guard" below. +- HTTP 404 (not found) — a deterministic result, not a transient failure. +- HTTP 400 / validation errors — retrying cannot change the outcome. +- Configuration errors (missing contract address or source account, surfaced as a 500 `BLOCKCHAIN_UNAVAILABLE`) — not transient. +- The optimistic `get_user_commitments` probe in `getUserCommitmentsFromChain`. Its failure is *expected* on contracts that don't implement that method and is handled by an id-based fallback; retrying an expected failure would only add latency. Transient errors are still covered, because the fallback `get_user_commitment_ids` read and the per-id `getCommitmentFromChain` reads each go through the retrying path. + +### Backoff algorithm + +Delays grow exponentially and use **equal jitter** so that many concurrent callers (e.g. parallel per-commitment reads) do not retry in lock-step: - **Request Process**: Submit a "Performance Exception Request" document detailing: - Reason for exception (e.g., legacy system limitation, extremely complex computation). - Impact analysis on system resources and user experience. - Proposed mitigation (e.g., aggressive caching, async processing). - Timeline for compliance or permanent waiver justification. -## 9. Cache Invalidation Patterns +## 10. Cache Invalidation Patterns Caching improves performance but must be managed carefully to prevent stale data. This section documents invalidation patterns used throughout CommitLabs. @@ -336,6 +362,6 @@ await cache.invalidate("commitlabs:marketplace:listings:"); await cache.delete(CacheKey.marketplaceStats()); ``` -## 10. Escalation Procedures +## 11. Escalation Procedures If a service consistently violates performance guidelines: diff --git a/package.json b/package.json index 8425eb6f..48bff99d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "eslint": "^8.0.0", "eslint-config-next": "^14.0.0", "happy-dom": "^20.7.0", + "ioredis": "^5.11.0", "node-fetch": "^3.3.2", "tsx": "^4.21.0", "typescript": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45017e02..16ef0aaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 5.6.0(react@18.3.1) recharts: specifier: ^3.7.0 - version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1) + version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1) tailwind-merge: specifier: ^3.4.0 version: 3.6.0 @@ -84,6 +84,9 @@ importers: happy-dom: specifier: ^20.7.0 version: 20.9.0 + ioredis: + specifier: ^5.11.0 + version: 5.11.0 node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -331,6 +334,9 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -383,24 +389,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -492,36 +502,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0': resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0': resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0': resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0': resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0': resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0': resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} @@ -617,24 +633,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -859,41 +879,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1156,6 +1184,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1281,6 +1313,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1730,6 +1766,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis@5.11.0: + resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1943,24 +1983,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2270,6 +2314,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -2414,6 +2466,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -2922,6 +2977,8 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3725,6 +3782,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3835,6 +3894,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4014,8 +4075,8 @@ snapshots: '@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -4034,7 +4095,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -4045,22 +4106,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4071,7 +4132,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -4445,6 +4506,18 @@ snapshots: internmap@2.0.3: {} + ioredis@5.11.0: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4955,7 +5028,7 @@ snapshots: dependencies: loose-envify: 1.4.0 - recharts@3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1): + recharts@3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1) clsx: 2.1.1 @@ -4965,7 +5038,7 @@ snapshots: immer: 10.2.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-is: 16.13.1 + react-is: 17.0.2 react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 @@ -4980,6 +5053,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -5173,6 +5252,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: diff --git a/src/lib/backend/contracts.ts b/src/lib/backend/contracts.ts index 61645b28..0ee7f00a 100644 --- a/src/lib/backend/contracts.ts +++ b/src/lib/backend/contracts.ts @@ -122,4 +122,4 @@ export async function earlyExitCommitmentOnChain( returnedAmount: "0", reference: buildMockReference("early_exit"), }; -} +} \ No newline at end of file diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 13e56273..c6938a80 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -48,14 +48,12 @@ export interface ChainCommitment { violationCount: number; createdAt?: string; expiresAt?: string; - contractVersion?: string; } export interface CreateCommitmentOnChainResult { commitmentId: string; commitment: ChainCommitment; txHash?: string; - contractVersion?: string; } export interface RecordAttestationOnChainParams { @@ -76,7 +74,6 @@ export interface RecordAttestationOnChainResult { feeEarned: string; recordedAt: string; txHash?: string; - contractVersion?: string; } export interface SettleCommitmentOnChainParams { @@ -89,77 +86,20 @@ export interface SettleCommitmentOnChainResult { txHash?: string; reference?: string; finalStatus: string; - contractVersion?: string; } type ContractCallMode = "read" | "write"; interface ContractInvocationResult { value: unknown; txHash?: string; - version: string; } const ANALYTICS_SCALE = 100; -const DEFAULT_RPC_TIMEOUT_MS = 30_000; function getRpcUrl(): string { return getBackendConfig().sorobanRpcUrl; } -function getRpcTimeoutMs(): number { - const raw = process.env.SOROBAN_RPC_TIMEOUT_MS; - if (!raw) return DEFAULT_RPC_TIMEOUT_MS; - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_RPC_TIMEOUT_MS; -} - -/** - * Races a Soroban RPC promise against an AbortSignal so that a controller- - * level timeout can cancel all in-flight RPC work simultaneously. When the - * signal fires before the promise settles the rejection is mapped to a - * GATEWAY_TIMEOUT BackendError with retryable: true. - * - * For write calls the timeout may fire after sendTransaction has already - * broadcast the transaction; callers must treat GATEWAY_TIMEOUT responses as - * "outcome unknown" and advise the user to verify on-chain using the txHash - * surfaced in the error details. - */ -function abortableRpc( - call: Promise, - signal: AbortSignal, - methodName: string, - timeoutMs: number, -): Promise { - return new Promise((resolve, reject) => { - const onAbort = () => - reject( - normalizeContractError(new Error("aborted"), { - code: "GATEWAY_TIMEOUT", - message: `Soroban RPC timed out after ${timeoutMs}ms for ${methodName}. The operation may still be processing on-chain.`, - status: 504, - details: { methodName, timeoutMs, retryable: true }, - }), - ); - - if (signal.aborted) { - onAbort(); - return; - } - - signal.addEventListener("abort", onAbort, { once: true }); - call.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error); - }, - ); - }); -} - function getNetworkPassphrase(): string { return getBackendConfig().networkPassphrase; } @@ -322,10 +262,168 @@ function normalizeContractError( }); } -function parseChainCommitment( - value: unknown, - contractVersion?: string, -): ChainCommitment { +// --------------------------------------------------------------------------- +// Retry-with-backoff for read-mode Soroban calls +// +// Transient RPC failures (429 / 503 / 504 / timeouts) on *read* calls are safe +// to retry because reads are idempotent — re-running one cannot change on-chain +// state. Write transactions are NEVER retried here: re-submitting a signed +// transaction risks double execution. The retry path is therefore exposed only +// through `invokeReadContractMethod`, whose call mode is hard-coded to "read". +// +// Both the attempt count and the cumulative backoff are bounded so that a flaky +// endpoint cannot stall a request indefinitely. +// --------------------------------------------------------------------------- + +/** Async sleep helper. Injectable so unit tests run without real timers. */ +export type SleepFn = (ms: number) => Promise; + +const defaultSleep: SleepFn = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Tunables for {@link retryWithBackoff}. */ +export interface RetryOptions { + /** Total attempts, including the first. Coerced to at least 1. */ + maxAttempts: number; + /** Delay used to seed the first backoff, in milliseconds. */ + baseDelayMs: number; + /** Hard ceiling for any single backoff delay, in milliseconds. */ + maxDelayMs: number; + /** Hard ceiling for the sum of all backoff delays in a single call. */ + maxTotalBackoffMs: number; + /** Growth factor applied to the delay ceiling after each failed attempt. */ + backoffMultiplier: number; + /** Returns true when an error is a transient failure worth retrying. */ + isRetryable: (error: unknown) => boolean; + /** Random source in [0, 1) used for jitter. Injectable for tests. */ + random?: () => number; + /** Sleep implementation. Injectable for tests. */ + sleep?: SleepFn; + /** Observability hook fired immediately before each backoff sleep. */ + onRetry?: (info: { + attempt: number; + delayMs: number; + error: unknown; + }) => void; +} + +/** + * Runs `operation` and retries it with bounded exponential backoff for as long + * as it keeps failing with a *retryable* error. + * + * Guarantees: + * - Bounded work: at most `maxAttempts` invocations and at most + * `maxTotalBackoffMs` of cumulative sleeping, so a failing dependency can + * never stall the caller indefinitely. + * - Non-retryable errors are re-thrown on first occurrence, untouched. + * - The error from the final attempt is re-thrown unchanged, so the caller's + * existing error handling (normalization, failure metrics) is unaffected. + * - No side effects of its own: it does not log or emit metrics. The caller + * decides what happens once retries are exhausted. + * + * Backoff uses "equal jitter" (half fixed, half random) so that many concurrent + * callers — e.g. parallel per-commitment reads — do not retry in lock-step and + * stampede the RPC endpoint. + */ +export async function retryWithBackoff( + operation: (attempt: number) => Promise, + options: RetryOptions, +): Promise { + const random = options.random ?? Math.random; + const sleep = options.sleep ?? defaultSleep; + const maxAttempts = Math.max(1, Math.floor(options.maxAttempts)); + + let totalBackoffMs = 0; + + for (let attempt = 1; ; attempt += 1) { + try { + // The operation receives the 1-based attempt number so lower layers can + // enforce per-attempt invariants (see assertRetrySafe). + return await operation(attempt); + } catch (error) { + const isLastAttempt = attempt >= maxAttempts; + if (isLastAttempt || !options.isRetryable(error)) { + throw error; + } + + // Exponential growth, capped per attempt, then jittered. + const ceiling = Math.min( + options.baseDelayMs * options.backoffMultiplier ** (attempt - 1), + options.maxDelayMs, + ); + const delayMs = ceiling / 2 + random() * (ceiling / 2); + + // Honour the cumulative backoff budget: rather than stalling, stop and + // surface the error if the next sleep would exceed it. + if (totalBackoffMs + delayMs > options.maxTotalBackoffMs) { + throw error; + } + totalBackoffMs += delayMs; + + options.onRetry?.({ attempt, delayMs, error }); + await sleep(delayMs); + } + } +} + +/** + * Bounded retry policy applied to read-mode Soroban calls only. Worst-case + * added latency is roughly `maxTotalBackoffMs` on top of the time spent in the + * failed attempts themselves. These values are intentionally conservative: + * they absorb brief RPC hiccups, not sustained outages. + */ +const READ_RETRY_CONFIG = { + maxAttempts: 3, + baseDelayMs: 200, + maxDelayMs: 2_000, + maxTotalBackoffMs: 4_000, + backoffMultiplier: 2, +} as const; + +/** + * Decides whether a failed Soroban call is a *transient* failure worth + * retrying. It reuses the retryable classification produced by + * {@link normalizeContractError} (429 / 503 / 504 / timeouts and generic + * gateway errors), so there is a single source of truth. Deterministic + * failures — 404 (not found) and 400 (validation) — are never retried. + */ +export function isRetryableContractError(error: unknown): boolean { + const normalized = normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Soroban read call failed.", + status: 502, + details: {}, + }); + return asRecord(normalized.details).retryable === true; +} + +/** + * Guard against retrying write transactions. + * + * Read calls are idempotent and may safely run multiple times. A write + * transaction must be submitted exactly once: retrying it (attempt > 1) risks + * a double submission. This invariant is enforced at the lowest level — inside + * {@link invokeContractMethod} — so it holds regardless of how a call is wired + * up. If the invariant is ever violated by a future change, this throws a + * non-retryable error *before* any transaction is submitted, converting a + * silent double-spend into a loud, safe failure. + * + * Exported so the guard can be unit tested directly. + */ +export function assertRetrySafe(mode: ContractCallMode, attempt: number): void { + if (attempt > 1 && mode !== "read") { + throw new BackendError({ + code: "BLOCKCHAIN_CALL_FAILED", + message: "Internal error: write transactions must never be retried.", + status: 500, + details: { mode, attempt }, + }); + } +} + +function parseChainCommitment(value: unknown): ChainCommitment { const raw = asRecord(value); const id = asString(raw.id ?? raw.commitmentId); @@ -353,14 +451,12 @@ function parseChainCommitment( violationCount: asNumber(raw.violationCount ?? raw.violation_count), createdAt: asString(raw.createdAt ?? raw.created_at) || undefined, expiresAt: asString(raw.expiresAt ?? raw.expires_at) || undefined, - contractVersion, }; } function parseCreateCommitmentResult( value: unknown, txHash?: string, - contractVersion?: string, ): CreateCommitmentOnChainResult { if (typeof value === "string") { return { @@ -375,31 +471,24 @@ function parseCreateCommitmentResult( currentValue: "0", feeEarned: "0", violationCount: 0, - contractVersion, }, txHash, - contractVersion, }; } const raw = asRecord(value); - const parsedCommitment = parseChainCommitment( - raw.commitment ?? raw, - contractVersion, - ); + const parsedCommitment = parseChainCommitment(raw.commitment ?? raw); return { commitmentId: parsedCommitment.id, commitment: parsedCommitment, txHash: asString(raw.txHash) || txHash, - contractVersion, }; } function parseAttestationResult( value: unknown, txHash?: string, - contractVersion?: string, ): RecordAttestationOnChainResult { const raw = asRecord(value); const attestationId = asString(raw.attestationId ?? raw.id); @@ -423,19 +512,15 @@ function parseAttestationResult( recordedAt: asString(raw.recordedAt ?? raw.recorded_at) || new Date().toISOString(), txHash: asString(raw.txHash) || txHash, - contractVersion, }; } -function parseCommitmentList( - value: unknown, - contractVersion?: string, -): ChainCommitment[] { +function parseCommitmentList(value: unknown): ChainCommitment[] { if (!Array.isArray(value)) { return []; } - return value.map((item) => parseChainCommitment(item, contractVersion)); + return value.map((item) => parseChainCommitment(item)); } async function waitForTransactionResult( @@ -476,7 +561,14 @@ async function invokeContractMethod( methodName: string, params: unknown[], mode: ContractCallMode, + attempt = 1, ): Promise { + // Guard: a write transaction must be submitted exactly once. Retrying one + // (attempt > 1) risks a double submission, so it is rejected before any + // network work is performed. Direct (non-retried) calls always pass + // attempt = 1 and are unaffected. + assertRetrySafe(mode, attempt); + if (!contractId) { throw new BackendError({ code: "BLOCKCHAIN_UNAVAILABLE", @@ -496,98 +588,95 @@ async function invokeContractMethod( }); } - const timeoutMs = getRpcTimeoutMs(); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const server = getSorobanServer(); - const contract = new Contract(contractId); - const account = - mode === "write" - ? await abortableRpc( - server.getAccount(sourcePublicKey), - controller.signal, - methodName, - timeoutMs, - ) - : new Account(sourcePublicKey, "0"); - - const operation = contract.call( - methodName, - ...params.map((value) => nativeToScVal(value)), - ); + const server = getSorobanServer(); + const contract = new Contract(contractId); + const account = + mode === "write" + ? await server.getAccount(sourcePublicKey) + : new Account(sourcePublicKey, "0"); + const operation = contract.call( + methodName, + ...params.map((value) => nativeToScVal(value)), + ); - const tx = new TransactionBuilder(account, { - fee: String(BASE_FEE), - networkPassphrase: getNetworkPassphrase(), - }) - .addOperation(operation) - .setTimeout(30) - .build(); - - const simulation = await abortableRpc( - server.simulateTransaction(tx), - controller.signal, - methodName, - timeoutMs, - ); + const tx = new TransactionBuilder(account, { + fee: String(BASE_FEE), + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simulation = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw normalizeContractError(new Error(simulation.error), { + code: "BLOCKCHAIN_CALL_FAILED", + message: `Soroban simulation failed for ${methodName}.`, + status: 502, + details: { methodName }, + }); + } - if (SorobanRpc.Api.isSimulationError(simulation)) { - throw normalizeContractError(new Error(simulation.error), { - code: "BLOCKCHAIN_CALL_FAILED", - message: `Soroban simulation failed for ${methodName}.`, - status: 502, - details: { methodName }, - }); - } + if (mode === "read") { + return { + value: simulation.result ? scValToNative(simulation.result.retval) : null, + }; + } - if (mode === "read") { - return { - value: simulation.result ? scValToNative(simulation.result.retval) : null, - }; - } + const sourceKeypair = getSourceKeypair(); + if (!sourceKeypair) { + throw new BackendError({ + code: "BLOCKCHAIN_UNAVAILABLE", + message: "Missing SOROBAN_SERVER_SECRET_KEY for write contract calls.", + status: 500, + details: { methodName }, + }); + } - const sourceKeypair = getSourceKeypair(); - if (!sourceKeypair) { - throw new BackendError({ - code: "BLOCKCHAIN_UNAVAILABLE", - message: "Missing SOROBAN_SERVER_SECRET_KEY for write contract calls.", - status: 500, - details: { methodName }, - }); - } + const preparedTx = await server.prepareTransaction(tx); + preparedTx.sign(sourceKeypair); + const sendResult = await server.sendTransaction(preparedTx); + const txHash = sendResult.hash; - const preparedTx = await abortableRpc( - server.prepareTransaction(tx), - controller.signal, - methodName, - timeoutMs, - ); - preparedTx.sign(sourceKeypair); + const onChainValue = await waitForTransactionResult(server, txHash); + return { value: onChainValue, txHash }; +} - const sendResult = await abortableRpc( - server.sendTransaction(preparedTx), - controller.signal, - methodName, - timeoutMs, - ); - const txHash = sendResult.hash; - - // waitForTransactionResult has its own internal polling loop; we race it - // against the same abort signal so a slow confirmation also times out. - // A GATEWAY_TIMEOUT here means the tx was broadcast — callers should - // surface the txHash so users can verify the outcome on-chain. - const onChainValue = await abortableRpc( - waitForTransactionResult(server, txHash), - controller.signal, - methodName, - timeoutMs, - ); - return { value: onChainValue, txHash }; - } finally { - clearTimeout(timer); - } +/** + * Read-only counterpart of {@link invokeContractMethod} that adds bounded + * retry-with-exponential-backoff for transient RPC failures. + * + * Use this for ALL read-mode contract calls. The call mode is hard-coded to + * "read", so a write transaction can never be submitted — let alone + * re-submitted — through this path. Write calls must keep calling + * `invokeContractMethod(..., "write")` directly so each runs exactly once. + * + * Only failures classified retryable by {@link isRetryableContractError} are + * retried; attempts and total backoff are capped by {@link READ_RETRY_CONFIG}. + * The final error is propagated unchanged, so a caller's `incrementChainFailures` + * runs exactly once, only after retries are exhausted. + */ +async function invokeReadContractMethod( + contractId: string, + methodName: string, + params: unknown[], +): Promise { + return retryWithBackoff( + (attempt) => + invokeContractMethod(contractId, methodName, params, "read", attempt), + { + ...READ_RETRY_CONFIG, + isRetryable: isRetryableContractError, + onRetry: ({ attempt, delayMs, error }) => { + logInfo(undefined, "[soroban] retrying read after transient failure", { + methodName, + attempt, + delayMs: Math.round(delayMs), + error: error instanceof Error ? error.message : String(error), + }); + }, + }, + ); } function validateOwnerAddress(ownerAddress: string): void { @@ -626,11 +715,7 @@ export async function createCommitmentOnChain( void cache.delete(CacheKey.userCommitments(params.ownerAddress)); - return parseCreateCommitmentResult( - invocation.value, - invocation.txHash, - invocation.version, - ); + return parseCreateCommitmentResult(invocation.value, invocation.txHash); } catch (error) { // Increment chain failures counter on blockchain operation failures const countersAdapter = getCountersAdapter(); @@ -665,25 +750,23 @@ export async function getCommitmentFromChain( } logInfo(undefined, "[cache] miss commitment", { commitmentId }); - const invocation = await invokeContractMethod( + // Read call: wrapped with bounded retry-and-backoff for transient failures. + const invocation = await invokeReadContractMethod( getContractId("commitmentCore"), "get_commitment", [commitmentId], - "read", ); // Increment successful actions counter on successful chain read const countersAdapter = getCountersAdapter(); void countersAdapter.incrementSuccessfulActions(); // Fire and forget for metrics - const commitment = parseChainCommitment( - invocation.value, - invocation.version, - ); + const commitment = parseChainCommitment(invocation.value); await cache.set(cacheKey, commitment, CacheTTL.COMMITMENT_DETAIL); return commitment; } catch (error) { - // Increment chain failures counter on blockchain operation failures + // Increment chain failures counter on blockchain operation failures. + // Reached only after read retries (if any) have been exhausted. const countersAdapter = getCountersAdapter(); void countersAdapter.incrementChainFailures(); // Fire and forget for metrics @@ -713,16 +796,20 @@ export async function getUserCommitmentsFromChain( const contractId = getContractId("commitmentCore"); try { + // Optimistic probe. `get_user_commitments` may not exist on every + // deployed contract, and its failure is expected and handled by the + // id-based fallback below — so it is deliberately NOT retried. Retrying + // an expected failure would only add latency. Genuine transient errors + // are still covered: the fallback `get_user_commitment_ids` read and the + // per-id `getCommitmentFromChain` reads each go through + // `invokeReadContractMethod` and so are retried. const directResult = await invokeContractMethod( contractId, "get_user_commitments", [ownerAddress], "read", ); - const commitments = parseCommitmentList( - directResult.value, - directResult.version, - ); + const commitments = parseCommitmentList(directResult.value); if (commitments.length > 0) { await cache.set(cacheKey, commitments, CacheTTL.USER_COMMITMENTS); // Increment successful actions counter on successful chain read @@ -736,11 +823,11 @@ export async function getUserCommitmentsFromChain( } } - const idsResult = await invokeContractMethod( + // Read call: wrapped with bounded retry-and-backoff for transient failures. + const idsResult = await invokeReadContractMethod( contractId, "get_user_commitment_ids", [ownerAddress], - "read", ); const commitmentIds = Array.isArray(idsResult.value) ? idsResult.value.map((id) => asString(id)).filter(Boolean) @@ -755,7 +842,8 @@ export async function getUserCommitmentsFromChain( void countersAdapter.incrementSuccessfulActions(); return commitments; } catch (error) { - // Increment chain failures counter on blockchain operation failures + // Increment chain failures counter on blockchain operation failures. + // Reached only after read retries (if any) have been exhausted. const countersAdapter = getCountersAdapter(); void countersAdapter.incrementChainFailures(); @@ -812,11 +900,7 @@ export async function recordAttestationOnChain( ); } - return parseAttestationResult( - invocation.value, - invocation.txHash, - invocation.version, - ); + return parseAttestationResult(invocation.value, invocation.txHash); } catch (error) { // Increment chain failures counter on blockchain operation failures const countersAdapter = getCountersAdapter(); @@ -904,7 +988,6 @@ export async function settleCommitmentOnChain( settlementAmount, finalStatus, txHash: invocation.txHash, - contractVersion: invocation.version, reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_SETTLE_COMMITMENT", @@ -981,7 +1064,6 @@ export async function earlyExitCommitmentOnChain( penaltyAmount, finalStatus, txHash: invocation.txHash, - contractVersion: invocation.version, reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` }; } catch (error) { @@ -992,69 +1074,4 @@ export async function earlyExitCommitmentOnChain( details: { method: 'early_exit_commitment', commitmentId: params.commitmentId } }); } -} - -export interface TransferOwnershipParams { - commitmentId: string; - fromAddress: string; - toAddress: string; -} - -export interface TransferOwnershipResult { - commitmentId: string; - newOwner: string; - txHash?: string; - reference?: string; -} - -export async function transferOwnership( - params: TransferOwnershipParams, -): Promise { - try { - if (!params.commitmentId) { - throw new BackendError({ - code: "BAD_REQUEST", - message: "Missing commitment id for ownership transfer.", - status: 400, - }); - } - validateOwnerAddress(params.fromAddress); - validateOwnerAddress(params.toAddress); - - const invocation = await invokeContractMethod( - getContractId("commitmentCore"), - "transfer_ownership", - [ - nativeToScVal(params.commitmentId), - new Address(params.fromAddress).toScVal(), - new Address(params.toAddress).toScVal(), - ], - "write", - ); - - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementSuccessfulActions(); - - void cache.delete(CacheKey.commitment(params.commitmentId)); - void cache.delete(CacheKey.userCommitments(params.fromAddress)); - void cache.delete(CacheKey.userCommitments(params.toAddress)); - - const result = asRecord(invocation.value); - return { - commitmentId: params.commitmentId, - newOwner: asString(result.newOwner, params.toAddress), - txHash: invocation.txHash, - reference: invocation.txHash ? undefined : "TODO_CHAIN_CALL_TRANSFER_OWNERSHIP", - }; - } catch (error) { - const countersAdapter = getCountersAdapter(); - void countersAdapter.incrementChainFailures(); - - throw normalizeBackendError(error, { - code: "BLOCKCHAIN_CALL_FAILED", - message: "Unable to transfer commitment ownership on chain.", - status: 502, - details: { method: "transfer_ownership", commitmentId: params.commitmentId }, - }); - } -} +} \ No newline at end of file diff --git a/tests/api/contracts.service.retry.test.ts b/tests/api/contracts.service.retry.test.ts new file mode 100644 index 00000000..e90e7697 --- /dev/null +++ b/tests/api/contracts.service.retry.test.ts @@ -0,0 +1,417 @@ +/** + * Tests for the read-call retry/backoff feature in the contracts service. + * + * Written for Vitest. For Jest: replace `vi` with `jest` and the import below + * with `@jest/globals` — the assertion and mock APIs used here are identical. + * + * Targets the three pure pieces of the feature: + * - retryWithBackoff — the bounded exponential-backoff loop + * - isRetryableContractError — the transient-vs-deterministic classifier + * - assertRetrySafe — the guard that forbids retrying writes + * + * `retryWithBackoff` accepts injectable `sleep` and `random`, so no real + * timers are used and every test is deterministic. + */ + + +import { describe, it, expect, vi } from "vitest"; + + + +// The contracts service imports cache / counters / config / logger at module +// load. None of that is exercised by these unit tests, so the modules are +// stubbed to keep the test hermetic and free of I/O. +vi.mock("ioredis", () => ({ default: class {} })); +vi.mock("@/lib/backend/cache/factory", () => ({ + cache: { + get: vi.fn(async () => null), + set: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + }, +})); +vi.mock("@/lib/backend/counters/provider", () => ({ + getCountersAdapter: () => ({ + incrementSuccessfulActions: vi.fn(), + incrementChainFailures: vi.fn(), + }), +})); +vi.mock("@/lib/backend/config", () => ({ + getBackendConfig: () => ({ + sorobanRpcUrl: "https://example.invalid", + networkPassphrase: "TEST", + contractAddresses: { commitmentCore: "", attestationEngine: "" }, + }), +})); +vi.mock("@/lib/backend/logger", () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); +vi.mock("@/lib/backend/cache/index", () => ({ + CacheKey: { + commitment: (id: string) => `commitment:${id}`, + userCommitments: (a: string) => `user-commitments:${a}`, + }, + CacheTTL: { COMMITMENT_DETAIL: 60, USER_COMMITMENTS: 60 }, +})); + +// NOTE: @/lib/backend/errors is intentionally NOT mocked — the classifier +// relies on `instanceof BackendError`, which requires the real class. +import { BackendError } from "@/lib/backend/errors"; +import { + retryWithBackoff, + isRetryableContractError, + assertRetrySafe, + type RetryOptions, +} from "@/lib/backend/services/contracts"; + +/** Deterministic defaults: minimum jitter, instant sleep, everything retryable. */ +function baseOptions(overrides: Partial = {}): RetryOptions { + return { + maxAttempts: 3, + baseDelayMs: 200, + maxDelayMs: 2_000, + maxTotalBackoffMs: 10_000, + backoffMultiplier: 2, + isRetryable: () => true, + random: () => 0, // minimum jitter -> delay === ceiling / 2 + sleep: async () => {}, + ...overrides, + }; +} + +describe("retryWithBackoff", () => { + it("returns immediately on success without sleeping", async () => { + const op = vi.fn().mockResolvedValue("ok"); + const sleep = vi.fn(async () => {}); + + const result = await retryWithBackoff(op, baseOptions({ sleep })); + + expect(result).toBe("ok"); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("retries a retryable failure and then succeeds", async () => { + const op = vi + .fn() + .mockRejectedValueOnce(new Error("503 service unavailable")) + .mockResolvedValue("ok"); + const sleep = vi.fn(async () => {}); + + const result = await retryWithBackoff(op, baseOptions({ sleep })); + + expect(result).toBe("ok"); + expect(op).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("does not retry a non-retryable failure", async () => { + const err = new Error("deterministic failure"); + const op = vi.fn().mockRejectedValue(err); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff(op, baseOptions({ isRetryable: () => false, sleep })), + ).rejects.toBe(err); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("stops after maxAttempts and rethrows the final error unchanged", async () => { + const last = new Error("attempt 3 failed"); + const op = vi + .fn() + .mockRejectedValueOnce(new Error("attempt 1")) + .mockRejectedValueOnce(new Error("attempt 2")) + .mockRejectedValueOnce(last); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff(op, baseOptions({ maxAttempts: 3, sleep })), + ).rejects.toBe(last); + expect(op).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it("never retries when maxAttempts is 1", async () => { + const err = new Error("503 retryable but capped"); + const op = vi.fn().mockRejectedValue(err); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff(op, baseOptions({ maxAttempts: 1, sleep })), + ).rejects.toBe(err); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("stops early when the total backoff budget would be exceeded", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const sleep = vi.fn(async () => {}); + + // base 200, mult 2, random()=>0 -> delays are 100, 200, 400, ... + // budget 250 allows the first sleep (100, total 100) but not the + // second (200 -> total 300 > 250), so it stops after attempt 2. + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 10, + baseDelayMs: 200, + maxDelayMs: 10_000, + maxTotalBackoffMs: 250, + sleep, + }), + ), + ).rejects.toThrow("flaky"); + expect(op).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("does not retry at all when the backoff budget is zero", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff( + op, + baseOptions({ maxAttempts: 5, maxTotalBackoffMs: 0, sleep }), + ), + ).rejects.toThrow("flaky"); + expect(op).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("applies exponential backoff with the configured multiplier", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const delays: number[] = []; + const sleep = vi.fn(async (ms: number) => { + delays.push(ms); + }); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 4, + baseDelayMs: 100, + maxDelayMs: 10_000, + backoffMultiplier: 2, + random: () => 0, // delay === ceiling / 2 + sleep, + }), + ), + ).rejects.toThrow(); + + // ceilings 100, 200, 400 -> delays (ceiling / 2): 50, 100, 200 + expect(delays).toEqual([50, 100, 200]); + }); + + it("caps an individual backoff delay at maxDelayMs", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const delays: number[] = []; + const sleep = vi.fn(async (ms: number) => { + delays.push(ms); + }); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 5, + baseDelayMs: 1_000, + maxDelayMs: 1_500, // ceiling clamped here + maxTotalBackoffMs: 100_000, + backoffMultiplier: 10, + random: () => 1, // maximum jitter -> delay === ceiling + sleep, + }), + ), + ).rejects.toThrow(); + + // raw ceilings 1000, 10000, 100000, 1000000 -> clamped to 1000, 1500, 1500, 1500 + expect(delays).toEqual([1_000, 1_500, 1_500, 1_500]); + }); + + it("adds jitter between ceiling/2 and ceiling", async () => { + const op = vi.fn().mockRejectedValue(new Error("flaky")); + const delays: number[] = []; + const sleep = vi.fn(async (ms: number) => { + delays.push(ms); + }); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 2, + baseDelayMs: 400, + backoffMultiplier: 2, + random: () => 0.5, // mid jitter -> delay = 200 + 0.5 * 200 + sleep, + }), + ), + ).rejects.toThrow(); + + expect(delays).toEqual([300]); + }); + + it("re-evaluates retryability on every attempt", async () => { + // First failure is retryable, second is not -> stop on the second. + const op = vi + .fn() + .mockRejectedValueOnce(new Error("RETRYABLE blip")) + .mockRejectedValueOnce(new Error("permanent failure")); + const sleep = vi.fn(async () => {}); + + await expect( + retryWithBackoff( + op, + baseOptions({ + maxAttempts: 5, + isRetryable: (e) => String((e as Error).message).includes("RETRYABLE"), + sleep, + }), + ), + ).rejects.toThrow("permanent failure"); + expect(op).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it("passes the 1-based attempt number to the operation", async () => { + const seen: number[] = []; + const op = vi.fn(async (attempt: number) => { + seen.push(attempt); + throw new Error("flaky"); + }); + + await expect( + retryWithBackoff(op, baseOptions({ maxAttempts: 3 })), + ).rejects.toThrow(); + expect(seen).toEqual([1, 2, 3]); + }); + + it("invokes the onRetry hook before each backoff sleep", async () => { + const op = vi + .fn() + .mockRejectedValueOnce(new Error("first")) + .mockResolvedValue("ok"); + const onRetry = vi.fn(); + + await retryWithBackoff(op, baseOptions({ onRetry, sleep: async () => {} })); + + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 1 }); + }); +}); + +describe("isRetryableContractError", () => { + it("treats timeouts as retryable", () => { + expect(isRetryableContractError(new Error("request timed out"))).toBe(true); + expect( + isRetryableContractError(new Error("operation deadline exceeded")), + ).toBe(true); + }); + + it("treats rate-limit / 429 errors as retryable", () => { + expect(isRetryableContractError(new Error("429 Too Many Requests"))).toBe( + true, + ); + expect(isRetryableContractError(new Error("rate limit reached"))).toBe( + true, + ); + }); + + it("treats a generic gateway failure as retryable for idempotent reads", () => { + // No specific pattern match -> falls through to the 5xx default. + expect(isRetryableContractError(new Error("socket hang up"))).toBe(true); + }); + + it("does NOT retry not-found errors", () => { + expect(isRetryableContractError(new Error("commitment not found"))).toBe( + false, + ); + }); + + it("does NOT retry validation errors", () => { + expect(isRetryableContractError(new Error("invalid parameters"))).toBe( + false, + ); + expect(isRetryableContractError(new Error("malformed request"))).toBe( + false, + ); + }); + + it("retries BackendErrors with retryable HTTP statuses", () => { + for (const status of [429, 503, 504]) { + const err = new BackendError({ + code: "BLOCKCHAIN_CALL_FAILED", + message: "transient", + status, + }); + expect(isRetryableContractError(err)).toBe(true); + } + }); + + it("does NOT retry configuration BackendErrors (e.g. missing config, 500)", () => { + const err = new BackendError({ + code: "BLOCKCHAIN_UNAVAILABLE", + message: "Missing Soroban contract configuration.", + status: 500, + }); + expect(isRetryableContractError(err)).toBe(false); + }); +}); + +describe("assertRetrySafe (write-retry guard)", () => { + it("allows read calls on any attempt", () => { + expect(() => assertRetrySafe("read", 1)).not.toThrow(); + expect(() => assertRetrySafe("read", 2)).not.toThrow(); + expect(() => assertRetrySafe("read", 10)).not.toThrow(); + }); + + it("allows a write call on the first (and only) attempt", () => { + expect(() => assertRetrySafe("write", 1)).not.toThrow(); + }); + + it("rejects a write call on any retry attempt", () => { + expect(() => assertRetrySafe("write", 2)).toThrow(BackendError); + expect(() => assertRetrySafe("write", 3)).toThrow(BackendError); + }); + + it("rejects write retries with a non-retryable 500 so the loop stops", () => { + let thrown: unknown; + try { + assertRetrySafe("write", 2); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(BackendError); + expect((thrown as BackendError).status).toBe(500); + // A 500 BackendError is classified non-retryable, so retryWithBackoff + // would surface it immediately instead of looping. + expect(isRetryableContractError(thrown)).toBe(false); + }); + + it("blocks a write accidentally wrapped in retryWithBackoff after one submit", async () => { + const submissions: number[] = []; + // Simulates invokeContractMethod for a write: it records each submission, + // runs the guard, and then 'fails transiently' on the first attempt. + const writeOp = async (attempt: number) => { + assertRetrySafe("write", attempt); // guard throws on attempt 2 + submissions.push(attempt); + throw new Error("503 transient after submit"); + }; + + await expect( + retryWithBackoff( + writeOp, + baseOptions({ maxAttempts: 5, isRetryable: isRetryableContractError }), + ), + ).rejects.toThrow(/must never be retried/); + + // The transaction is submitted exactly once; the guard blocks attempt 2. + expect(submissions).toEqual([1]); + }); +}); \ No newline at end of file