From c4932b7cd0b8a898583c1132e4cbffe3bcc21faf Mon Sep 17 00:00:00 2001 From: Ashiba Test Date: Thu, 9 Jul 2026 19:47:03 +0900 Subject: [PATCH 1/2] feat: add visible thin-driver retry boundary --- .changeset/visible-retry-boundary.md | 6 + README.md | 6 +- docs/concepts/concept-map.md | 2 +- docs/guide/runtime-boundary.md | 40 +++++ packages/driver-adapter-core/README.md | 6 + packages/driver-adapter-core/src/index.ts | 162 ++++++++++++++++++ .../tests/driver-adapter-core.test.ts | 112 ++++++++++++ packages/driver-adapter-pg/README.md | 4 +- packages/driver-adapter-pg/src/index.ts | 81 +++++++++ .../tests/driver-adapter-pg.test.ts | 25 +++ 10 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 .changeset/visible-retry-boundary.md diff --git a/.changeset/visible-retry-boundary.md b/.changeset/visible-retry-boundary.md new file mode 100644 index 0000000..540c216 --- /dev/null +++ b/.changeset/visible-retry-boundary.md @@ -0,0 +1,6 @@ +--- +"@ashiba-ts/driver-adapter-core": minor +"@ashiba-ts/driver-adapter-pg": minor +--- + +Add a visible retry boundary helper and PostgreSQL transient-error classification for caller-owned retry policies. The helper requires an explicit retry classifier and keeps transaction safety, idempotency, SAGA compensation, and final error handling in application code. diff --git a/README.md b/README.md index f61241a..a21ec04 100644 --- a/README.md +++ b/README.md @@ -119,8 +119,8 @@ Ashiba chooses DBMS and wrapped driver explicitly. PostgreSQL is the most comple | DBMS | Wrapped driver/tool | Package | Current status | |---|---|---|---| -| Shared driver contracts | driver adapter core | `@ashiba-ts/driver-adapter-core` | Shared feature query contracts and cardinality helpers. No ORM model or SQL DSL. | -| PostgreSQL | `pg` | `@ashiba-ts/driver-adapter-pg` | Most complete starter path. Includes generated query metadata, mapper-test lane, named-parameter binding, safe sort, optional-condition metadata, and tutorial coverage. | +| Shared driver contracts | driver adapter core | `@ashiba-ts/driver-adapter-core` | Shared feature query contracts, cardinality helpers, and explicit retry-policy helpers. No ORM model or SQL DSL. | +| PostgreSQL | `pg` | `@ashiba-ts/driver-adapter-pg` | Most complete starter path. Includes generated query metadata, mapper-test lane, named-parameter binding, safe sort, optional-condition metadata, transient-error classification, and tutorial coverage. | | PostgreSQL | `pg` testkit | `@ashiba-ts/testkit-adapter-pg` | ZTD mapper-test adapter used by the PostgreSQL starter. | | PostgreSQL | `pg_dump` | `@ashiba-ts/ddl-pull-pg-dump` | Optional helper for comparing production DDL from `pg_dump` with local DDL. | | MySQL | `mysql2` | `@ashiba-ts/driver-adapter-mysql2` | Driver adapter exists. Full `ashiba init` starter and testkit path are not complete yet. | @@ -259,7 +259,7 @@ npx ashiba config Ashiba is not an ORM runtime or SQL DSL. The CLI generator, SQL analysis, scaffolding, tests, and drift checks are development-time tools. -At runtime, applications may use a selected thin SQL execution adapter such as `@ashiba-ts/driver-adapter-pg`. That adapter owns narrow execution concerns: named-parameter binding, metadata-backed optional-condition compression, metadata-backed safe sort, stale metadata rejection, and observer events. It does not own entities, relations, lazy loading, unit of work, dirty tracking, or query planning. +At runtime, applications may use a selected thin SQL execution adapter such as `@ashiba-ts/driver-adapter-pg`. That adapter owns narrow execution concerns: named-parameter binding, metadata-backed optional-condition compression, metadata-backed safe sort, stale metadata rejection, observer events, and transient-error classification for explicit retry policies. It does not own entities, relations, lazy loading, unit of work, dirty tracking, query planning, hidden automatic retry, or SAGA compensation. The `.sql` file is the canonical source. Generated `query.sql.ts` is a runtime snapshot and must not be hand edited. After SQL-only edits, run refresh/check so `.sql`, `query.sql.ts`, and `query.meta.ts` stay synchronized. diff --git a/docs/concepts/concept-map.md b/docs/concepts/concept-map.md index c4bf6d6..ffd6e1c 100644 --- a/docs/concepts/concept-map.md +++ b/docs/concepts/concept-map.md @@ -80,7 +80,7 @@ These concepts belong to driver-neutral SQL libraries, production driver adapter | ID | Display name | Status | Notes | |---|---|---|---| -| `thin-driver-adapter` | Thin Driver Adapter | mostly done | `pg` adapter owns named binding, parameter checks, query-model-gated safe sort, optional-condition compression, stale metadata rejection, and observer events while avoiding ORM, relation loading, unit-of-work, query DSL, and transaction ownership. Starter code should keep the standard path option-light, expose frequent connection and transaction controls nearby, and leave rare driver policy to customer-owned code. Wrapper package names include the wrapped driver or tool name. | +| `thin-driver-adapter` | Thin Driver Adapter | mostly done | `pg` adapter owns named binding, parameter checks, query-model-gated safe sort, optional-condition compression, stale metadata rejection, observer events, and transient-error classification while avoiding ORM, relation loading, unit-of-work, query DSL, hidden automatic retry, SAGA compensation, and transaction ownership. Starter code should keep the standard path option-light, expose frequent connection, transaction, and explicit retry controls nearby, and leave business retry safety to customer-owned code. Wrapper package names include the wrapped driver or tool name. | | `named-parameter-binding` | Named Parameter Binding | mostly done | Source SQL uses named parameters such as `:name` or `@name`; DB driver wrappers compile them to driver placeholders. | | `parameter-contract-check` | Parameter Contract Check | mostly done | Missing and unused parameters fail before execution in binder and PostgreSQL adapter paths. | | `safe-sort-profile` | Safe Sort Profile | mostly done | DB driver wrapper-owned safe sort surface based on whitelisted profiles and CLI-generated query model metadata: source hash, root query shape, insertion position, order-by/comma mode, and sortable dictionary. Sort keys must exactly match the query model whitelist. | diff --git a/docs/guide/runtime-boundary.md b/docs/guide/runtime-boundary.md index 8e3494b..5f0eeb8 100644 --- a/docs/guide/runtime-boundary.md +++ b/docs/guide/runtime-boundary.md @@ -41,9 +41,49 @@ At runtime, the selected driver adapter can be used as a thin SQL execution boun - metadata-backed optional-condition compression - metadata-backed safe sort - observer events for logging +- transient DB error classification for caller-owned retry policies The adapter still executes SQL through the wrapped driver. It does not expose a SQL builder DSL, ORM model, object graph, or relation loader. +## Visible Retry Boundary + +Thin drivers may help applications handle transient database failures, but retry must stay visible. + +`@ashiba-ts/driver-adapter-core` provides `withAshibaRetry`, a small helper for caller-owned retry loops. It retries only when the caller passes an explicit `retryOn` classifier. The final error is rethrown as-is, and retry / give-up events can be observed by application logging. + +`@ashiba-ts/driver-adapter-pg` provides `classifyPostgresTransientError` and `isPostgresTransientError` for common PostgreSQL SQLSTATE and connection failures such as serialization failure, deadlock, shutdown, connection failure, and transient network errors. + +Example: + +```ts +import { withAshibaRetry } from '@ashiba-ts/driver-adapter-core'; +import { classifyPostgresTransientError } from '@ashiba-ts/driver-adapter-pg'; + +await withAshibaRetry( + { + maxAttempts: 3, + retryOn(error) { + const result = classifyPostgresTransientError(error); + return { retry: result.retryable, reason: result.reason }; + }, + }, + async () => { + // Application-owned transaction or query boundary call. + }, +); +``` + +This is intentionally not hidden automatic retry. Application code still owns: + +- whether the operation is safe to run again +- transaction scope +- idempotency keys or optimistic locking +- external side effects +- SAGA / compensation workflows +- how retries are logged, alerted, and capped + +Do not use a thin-driver retry helper to silently re-run arbitrary mutations after the application has performed non-database side effects. For commit-unknown or compensation-heavy workflows, keep the retry decision in application/SAGA code and use the driver classifier only as one input. + ## Feature Query Boundary Generated feature query code depends on the shared `FeatureQueryExecutor` contract from `@ashiba-ts/driver-adapter-core`. diff --git a/packages/driver-adapter-core/README.md b/packages/driver-adapter-core/README.md index a12257c..ea80997 100644 --- a/packages/driver-adapter-core/README.md +++ b/packages/driver-adapter-core/README.md @@ -16,6 +16,7 @@ It provides shared types and helpers for: - masked parameter logging - logger-ready execution events +- explicit retry policy helpers for visible transient-failure retry boundaries - feature query boundary types - `many` / `one` / `oneOrNull` cardinality helpers - dialect-extensible query model binding slots @@ -24,3 +25,8 @@ It provides shared types and helpers for: Application projects that use generated feature query boundaries should install this package alongside a concrete adapter such as `@ashiba-ts/driver-adapter-pg`. + +`withAshibaRetry` is intentionally policy-driven. It does not decide that SQL, +transactions, external side effects, or SAGA workflows are safe to execute again. +Application code must pass an explicit classifier and own idempotency, +compensation, and logging policy. diff --git a/packages/driver-adapter-core/src/index.ts b/packages/driver-adapter-core/src/index.ts index 0f393b0..c381ebf 100644 --- a/packages/driver-adapter-core/src/index.ts +++ b/packages/driver-adapter-core/src/index.ts @@ -62,6 +62,83 @@ export type AshibaSqlExecutionObserver = { emit(event: AshibaSqlExecutionEvent): void; }; +/** + * Caller-owned decision returned by a visible retry policy. + */ +export type AshibaRetryDecision = + | boolean + | { + retry: boolean; + reason?: string; + delayMs?: number; + }; + +/** + * Context passed to retry policy functions after a failed attempt. + */ +export type AshibaRetryContext = { + attempt: number; + maxAttempts: number; + elapsedMs: number; + error: unknown; +}; + +/** + * Structured retry event emitted by retry helpers. + */ +export type AshibaRetryEvent = { + phase: 'retry' | 'give-up'; + attempt: number; + maxAttempts: number; + elapsedMs: number; + delayMs?: number; + reason?: string; + error: { + name: string; + message: string; + code?: string; + cause?: string; + nextAction?: string; + }; +}; + +/** + * Application-provided observer hook for retry visibility. + */ +export type AshibaRetryObserver = { + emit(event: AshibaRetryEvent): void; +}; + +/** + * Explicit retry policy for thin-driver retry boundaries. + * + * The policy intentionally requires a retry classifier. Ashiba does not infer + * that arbitrary SQL or workflow code is safe to execute again. + */ +export type AshibaRetryPolicy = { + maxAttempts: number; + retryOn(error: unknown, context: AshibaRetryContext): AshibaRetryDecision; + delayMs?: number | ((context: AshibaRetryContext) => number); + observer?: AshibaRetryObserver; +}; + +/** + * Error raised when an invalid retry policy is passed to the shared helper. + */ +export class AshibaRetryPolicyError extends Error { + readonly code: 'ASHIBA_RETRY_POLICY_INVALID'; + readonly causeText: string; + readonly nextAction: string; + + constructor(message: string) { + super(message); + this.name = 'AshibaRetryPolicyError'; + this.code = 'ASHIBA_RETRY_POLICY_INVALID'; + this.causeText = 'The retry helper received a policy that cannot define a visible retry boundary.'; + this.nextAction = 'Pass maxAttempts >= 1 and an explicit retryOn classifier. Do not rely on hidden automatic retry.'; + } +} + /** * Allowed direction values for safe sort rendering. */ @@ -315,6 +392,56 @@ export async function queryOneOrNull( return rows[0] ?? null; } +/** + * Run caller-owned work under an explicit retry boundary. + * + * This helper retries only when the provided policy says the thrown error is + * retryable. It does not wrap the final error or decide business idempotency. + */ +export async function withAshibaRetry( + policy: AshibaRetryPolicy, + operation: (context: { attempt: number; maxAttempts: number }) => Promise, +): Promise { + assertRetryPolicy(policy); + const startedAt = Date.now(); + let attempt = 1; + + for (;;) { + try { + return await operation({ attempt, maxAttempts: policy.maxAttempts }); + } catch (error) { + const context: AshibaRetryContext = { + attempt, + maxAttempts: policy.maxAttempts, + elapsedMs: Date.now() - startedAt, + error, + }; + const decision = normalizeRetryDecision(policy.retryOn(error, context)); + const shouldRetry = decision.retry && attempt < policy.maxAttempts; + const delayMs = shouldRetry ? resolveRetryDelayMs(policy, context, decision) : undefined; + policy.observer?.emit({ + phase: shouldRetry ? 'retry' : 'give-up', + attempt, + maxAttempts: policy.maxAttempts, + elapsedMs: context.elapsedMs, + ...(delayMs !== undefined ? { delayMs } : {}), + ...(decision.reason ? { reason: decision.reason } : {}), + error: normalizeError(error), + }); + + if (!shouldRetry) { + throw error; + } + + const retryDelayMs = delayMs ?? 0; + if (retryDelayMs > 0) { + await sleep(retryDelayMs); + } + attempt += 1; + } + } +} + /** * Error raised when a safe sort request violates the reviewed query model or sort profile. */ @@ -397,6 +524,41 @@ export function normalizeError(error: unknown): { name: string; message: string; }; } +function assertRetryPolicy(policy: AshibaRetryPolicy): void { + if (!Number.isInteger(policy.maxAttempts) || policy.maxAttempts < 1) { + throw new AshibaRetryPolicyError('Retry policy maxAttempts must be an integer greater than or equal to 1.'); + } + if (typeof policy.retryOn !== 'function') { + throw new AshibaRetryPolicyError('Retry policy must provide an explicit retryOn classifier.'); + } +} + +function normalizeRetryDecision(decision: AshibaRetryDecision): { retry: boolean; reason?: string; delayMs?: number } { + if (typeof decision === 'boolean') { + return { retry: decision }; + } + return { + retry: decision.retry, + ...(decision.reason ? { reason: decision.reason } : {}), + ...(decision.delayMs !== undefined ? { delayMs: decision.delayMs } : {}), + }; +} + +function resolveRetryDelayMs( + policy: AshibaRetryPolicy, + context: AshibaRetryContext, + decision: { delayMs?: number }, +): number { + const value = decision.delayMs ?? (typeof policy.delayMs === 'function' ? policy.delayMs(context) : policy.delayMs) ?? 0; + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} + function describeSortErrorCause(code: AshibaSortError['code']): string { switch (code) { case 'ASHIBA_EMPTY_SORT_PROFILE': diff --git a/packages/driver-adapter-core/tests/driver-adapter-core.test.ts b/packages/driver-adapter-core/tests/driver-adapter-core.test.ts index 1817fcf..60866b5 100644 --- a/packages/driver-adapter-core/tests/driver-adapter-core.test.ts +++ b/packages/driver-adapter-core/tests/driver-adapter-core.test.ts @@ -2,12 +2,15 @@ import { describe, expect, test } from 'vitest'; import { AshibaSortError, FeatureQueryCardinalityError, + AshibaRetryPolicyError, maskParams, normalizeError, queryMany, queryOne, queryOneOrNull, renderSafeOrderBy, + withAshibaRetry, + type AshibaRetryEvent, type FeatureQueryExecutor, type FeatureQuerySource, } from '../src/index.js'; @@ -148,6 +151,115 @@ describe('@ashiba-ts/driver-adapter-core', () => { nextAction: 'Use queryMany for mutation workflows that need to handle zero rows, or use queryOne only when the SQL contract really guarantees exactly one row.', }); }); + + test('runs caller-owned work under an explicit retry policy', async () => { + const events: AshibaRetryEvent[] = []; + const attempts: number[] = []; + const result = await withAshibaRetry( + { + maxAttempts: 3, + retryOn(error) { + return { + retry: error instanceof Error && error.message === 'transient', + reason: 'test-transient', + }; + }, + observer: { + emit(event) { + events.push(event); + }, + }, + }, + async ({ attempt }) => { + attempts.push(attempt); + if (attempt < 2) { + throw new Error('transient'); + } + return 'ok'; + }, + ); + + expect(result).toBe('ok'); + expect(attempts).toEqual([1, 2]); + expect(events).toEqual([ + expect.objectContaining({ + phase: 'retry', + attempt: 1, + maxAttempts: 3, + reason: 'test-transient', + error: { name: 'Error', message: 'transient' }, + }), + ]); + }); + + test('does not retry when the explicit policy rejects the error', async () => { + const events: AshibaRetryEvent[] = []; + const fatal = new Error('fatal'); + + await expect(withAshibaRetry( + { + maxAttempts: 3, + retryOn: () => false, + observer: { + emit(event) { + events.push(event); + }, + }, + }, + async () => { + throw fatal; + }, + )).rejects.toBe(fatal); + + expect(events).toEqual([ + expect.objectContaining({ + phase: 'give-up', + attempt: 1, + maxAttempts: 3, + error: { name: 'Error', message: 'fatal' }, + }), + ]); + }); + + test('stops retrying at maxAttempts and rethrows the original error', async () => { + const events: AshibaRetryEvent[] = []; + const errors = [new Error('transient-1'), new Error('transient-2')]; + let callCount = 0; + + await expect(withAshibaRetry( + { + maxAttempts: 2, + retryOn: () => true, + delayMs: ({ attempt }) => attempt, + observer: { + emit(event) { + events.push(event); + }, + }, + }, + async () => { + const error = errors[callCount] ?? new Error('unexpected'); + callCount += 1; + throw error; + }, + )).rejects.toBe(errors[1]); + + expect(callCount).toBe(2); + expect(events).toEqual([ + expect.objectContaining({ phase: 'retry', attempt: 1, delayMs: 1 }), + expect.objectContaining({ phase: 'give-up', attempt: 2 }), + ]); + }); + + test('requires a visible retry policy', async () => { + await expect(withAshibaRetry( + { + maxAttempts: 0, + retryOn: () => true, + }, + async () => 'never', + )).rejects.toBeInstanceOf(AshibaRetryPolicyError); + }); }); function buildFeatureQuerySource(id: string): FeatureQuerySource { diff --git a/packages/driver-adapter-pg/README.md b/packages/driver-adapter-pg/README.md index 9ec6522..eaeee21 100644 --- a/packages/driver-adapter-pg/README.md +++ b/packages/driver-adapter-pg/README.md @@ -13,7 +13,7 @@ Start with the repository README for the full SQL-first workflow: ## What This Package Owns -It owns named parameter binding, parameter contract checks, logger-ready execution events, metadata-backed optional-condition compression, and metadata-backed safe sort rendering. It does not own transactions, business SQL, ORM behavior, relation loading, lazy loading, unit of work, or DDL pull. +It owns named parameter binding, parameter contract checks, logger-ready execution events, metadata-backed optional-condition compression, metadata-backed safe sort rendering, and PostgreSQL transient-error classification for caller-owned retry policies. It does not own transactions, business SQL, ORM behavior, relation loading, lazy loading, unit of work, SAGA/compensation workflows, hidden automatic retry, or DDL pull. Application code should call the adapter with a file-backed or generated query source object containing SQL text, SQL path, and query model metadata. The adapter still passes a SQL string to the wrapped `pg` client internally, but it does not expose an `execute(sql: string, ...)` convenience boundary for arbitrary runtime SQL input. @@ -25,4 +25,6 @@ Root compound queries such as `UNION`, `INTERSECT`, and `EXCEPT` are rejected fr Current contract tests cover `pg` compatible query delegation, named parameter binding, unused parameter rejection before driver execution, query-model-gated safe sort rendering, stale metadata rejection, masked/unmasked observer events, and error event emission. +For transient database failures, use `classifyPostgresTransientError` or `isPostgresTransientError` as input to an explicit retry boundary such as `withAshibaRetry` from `@ashiba-ts/driver-adapter-core`. The classifier only identifies retry candidates; application code still decides whether the operation is safe to run again. + Live PostgreSQL smoke can be run by setting `ASHIBA_TEST_DATABASE_URL` or `DATABASE_URL` before `pnpm test`. Without that environment variable, the live smoke is skipped. diff --git a/packages/driver-adapter-pg/src/index.ts b/packages/driver-adapter-pg/src/index.ts index 5b5b471..5796b5f 100644 --- a/packages/driver-adapter-pg/src/index.ts +++ b/packages/driver-adapter-pg/src/index.ts @@ -78,6 +78,16 @@ export type AshibaPostgresAdapter = { ): Promise>; }; +/** + * Classification result for PostgreSQL errors that may be retried by a + * caller-owned visible retry boundary. + */ +export type AshibaPostgresRetryClassification = { + retryable: boolean; + code?: string; + reason?: string; +}; + /** * Error raised when provided named parameters do not match query model metadata. */ @@ -135,6 +145,34 @@ type TextEdit = TextRange & { text: string; }; +const POSTGRES_TRANSIENT_SQLSTATES = new Set([ + '40001', // serialization_failure + '40P01', // deadlock_detected + '53300', // too_many_connections + '57P01', // admin_shutdown + '57P02', // crash_shutdown + '57P03', // cannot_connect_now + '08000', // connection_exception + '08001', // sqlclient_unable_to_establish_sqlconnection + '08003', // connection_does_not_exist + '08004', // sqlserver_rejected_establishment_of_sqlconnection + '08006', // connection_failure + '08007', // transaction_resolution_unknown + '08P01', // protocol_violation +]); + +const POSTGRES_TRANSIENT_NODE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'ETIMEDOUT', + 'ENETDOWN', + 'ENETRESET', + 'ENETUNREACH', + 'EHOSTDOWN', + 'EHOSTUNREACH', +]); + /** * Create a thin adapter around a pg-compatible client or pool. */ @@ -233,6 +271,41 @@ export function createPostgresAdapter( }; } +/** + * Classify PostgreSQL/pg errors that can be candidates for an explicit retry policy. + * + * This does not mean the operation is safe to retry. Application code still + * owns transaction boundaries, idempotency, and SAGA/compensation decisions. + */ +export function classifyPostgresTransientError(error: unknown): AshibaPostgresRetryClassification { + const code = getErrorCode(error); + if (!code) { + return { retryable: false }; + } + if (POSTGRES_TRANSIENT_SQLSTATES.has(code)) { + return { + retryable: true, + code, + reason: `PostgreSQL reported transient SQLSTATE ${code}.`, + }; + } + if (POSTGRES_TRANSIENT_NODE_ERROR_CODES.has(code)) { + return { + retryable: true, + code, + reason: `Node PostgreSQL driver reported transient connection error ${code}.`, + }; + } + return { retryable: false, code }; +} + +/** + * Return true when the error is a PostgreSQL transient failure candidate. + */ +export function isPostgresTransientError(error: unknown): boolean { + return classifyPostgresTransientError(error).retryable; +} + function buildSqlSourceWarnings( query: AshibaPostgresQuerySource, metadata: AshibaSqlExecutionMetadata, @@ -855,3 +928,11 @@ function isPostgresEscapeStringStart(sql: string, quoteIndex: number): boolean { const beforeMarker = sql[quoteIndex - 2] ?? ''; return !/[A-Za-z0-9_$]/.test(beforeMarker); } + +function getErrorCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} diff --git a/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts b/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts index f0d9724..ad191e2 100644 --- a/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts +++ b/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts @@ -3,13 +3,38 @@ import fc from 'fast-check'; import { describe, expect, test } from 'vitest'; import { AshibaParameterError, + classifyPostgresTransientError, createPostgresAdapter, + isPostgresTransientError, type AshibaPostgresQueryModel, type NodePostgresQueryable, } from '../src/index.js'; import { AshibaSortError, type AshibaSqlExecutionEvent } from '@ashiba-ts/driver-adapter-core'; describe('@ashiba-ts/driver-adapter-pg', () => { + test('classifies PostgreSQL transient retry candidates without deciding idempotency', () => { + expect(classifyPostgresTransientError({ code: '40001' })).toEqual({ + retryable: true, + code: '40001', + reason: 'PostgreSQL reported transient SQLSTATE 40001.', + }); + expect(classifyPostgresTransientError({ code: '40P01' })).toMatchObject({ + retryable: true, + code: '40P01', + }); + expect(classifyPostgresTransientError({ code: 'ECONNRESET' })).toEqual({ + retryable: true, + code: 'ECONNRESET', + reason: 'Node PostgreSQL driver reported transient connection error ECONNRESET.', + }); + expect(classifyPostgresTransientError({ code: '23505' })).toEqual({ + retryable: false, + code: '23505', + }); + expect(isPostgresTransientError({ code: '57P03' })).toBe(true); + expect(isPostgresTransientError(new Error('plain'))).toBe(false); + }); + test('executes named-parameter SQL through a pg compatible client', async () => { const calls: Array<{ sql: string; values: readonly unknown[] }> = []; const client: NodePostgresQueryable = { From 04d6e31987ab9349e40bd034242ea387d5aec0b4 Mon Sep 17 00:00:00 2001 From: Ashiba Test Date: Thu, 9 Jul 2026 19:59:37 +0900 Subject: [PATCH 2/2] fix: harden visible retry boundary --- packages/driver-adapter-core/src/index.ts | 42 ++++- .../tests/driver-adapter-core.test.ts | 151 ++++++++++++++++++ packages/driver-adapter-pg/src/index.ts | 1 - .../tests/driver-adapter-pg.test.ts | 4 + 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/packages/driver-adapter-core/src/index.ts b/packages/driver-adapter-core/src/index.ts index c381ebf..7b4812d 100644 --- a/packages/driver-adapter-core/src/index.ts +++ b/packages/driver-adapter-core/src/index.ts @@ -129,13 +129,23 @@ export class AshibaRetryPolicyError extends Error { readonly code: 'ASHIBA_RETRY_POLICY_INVALID'; readonly causeText: string; readonly nextAction: string; + readonly details?: { + operationError?: ReturnType; + policyError?: ReturnType; + }; - constructor(message: string) { - super(message); + constructor(message: string, details?: { operationError?: unknown; policyError?: unknown }) { + super(message, details?.policyError ? { cause: details.policyError } : undefined); this.name = 'AshibaRetryPolicyError'; this.code = 'ASHIBA_RETRY_POLICY_INVALID'; this.causeText = 'The retry helper received a policy that cannot define a visible retry boundary.'; this.nextAction = 'Pass maxAttempts >= 1 and an explicit retryOn classifier. Do not rely on hidden automatic retry.'; + this.details = details + ? { + ...(details.operationError !== undefined ? { operationError: normalizeError(details.operationError) } : {}), + ...(details.policyError !== undefined ? { policyError: normalizeError(details.policyError) } : {}), + } + : undefined; } } @@ -416,10 +426,26 @@ export async function withAshibaRetry( elapsedMs: Date.now() - startedAt, error, }; - const decision = normalizeRetryDecision(policy.retryOn(error, context)); + let decision: { retry: boolean; reason?: string; delayMs?: number }; + try { + decision = normalizeRetryDecision(policy.retryOn(error, context)); + } catch (policyError) { + safeEmitRetryEvent(policy, { + phase: 'give-up', + attempt, + maxAttempts: policy.maxAttempts, + elapsedMs: context.elapsedMs, + reason: 'retryOn threw while classifying the operation failure', + error: normalizeError(error), + }); + throw new AshibaRetryPolicyError( + 'Retry policy retryOn threw while classifying an operation failure.', + { operationError: error, policyError }, + ); + } const shouldRetry = decision.retry && attempt < policy.maxAttempts; const delayMs = shouldRetry ? resolveRetryDelayMs(policy, context, decision) : undefined; - policy.observer?.emit({ + safeEmitRetryEvent(policy, { phase: shouldRetry ? 'retry' : 'give-up', attempt, maxAttempts: policy.maxAttempts, @@ -559,6 +585,14 @@ function sleep(delayMs: number): Promise { }); } +function safeEmitRetryEvent(policy: AshibaRetryPolicy, event: AshibaRetryEvent): void { + try { + policy.observer?.emit(event); + } catch { + // Observer errors must not mask the operation error. + } +} + function describeSortErrorCause(code: AshibaSortError['code']): string { switch (code) { case 'ASHIBA_EMPTY_SORT_PROFILE': diff --git a/packages/driver-adapter-core/tests/driver-adapter-core.test.ts b/packages/driver-adapter-core/tests/driver-adapter-core.test.ts index 60866b5..4291fc6 100644 --- a/packages/driver-adapter-core/tests/driver-adapter-core.test.ts +++ b/packages/driver-adapter-core/tests/driver-adapter-core.test.ts @@ -251,6 +251,157 @@ describe('@ashiba-ts/driver-adapter-core', () => { ]); }); + test('decision delay overrides policy delay and invalid delays are clamped', async () => { + const events: AshibaRetryEvent[] = []; + let callCount = 0; + + await expect(withAshibaRetry( + { + maxAttempts: 2, + retryOn() { + return { retry: true, delayMs: Number.POSITIVE_INFINITY }; + }, + delayMs: 99, + observer: { + emit(event) { + events.push(event); + }, + }, + }, + async () => { + callCount += 1; + if (callCount === 1) { + throw new Error('transient'); + } + return 'ok'; + }, + )).resolves.toBe('ok'); + + expect(events).toEqual([ + expect.objectContaining({ phase: 'retry', attempt: 1, delayMs: 0 }), + ]); + + events.length = 0; + callCount = 0; + await expect(withAshibaRetry( + { + maxAttempts: 2, + retryOn: () => true, + delayMs: () => -1, + observer: { + emit(event) { + events.push(event); + }, + }, + }, + async () => { + callCount += 1; + if (callCount === 1) { + throw new Error('transient'); + } + return 'ok'; + }, + )).resolves.toBe('ok'); + + expect(events).toEqual([ + expect.objectContaining({ phase: 'retry', attempt: 1, delayMs: 0 }), + ]); + }); + + test('normalizes non-error throws in retry events', async () => { + const events: AshibaRetryEvent[] = []; + + await expect(withAshibaRetry( + { + maxAttempts: 2, + retryOn: () => false, + observer: { + emit(event) { + events.push(event); + }, + }, + }, + async () => { + throw 'string error'; + }, + )).rejects.toBe('string error'); + + expect(events).toEqual([ + expect.objectContaining({ + phase: 'give-up', + error: { name: 'Error', message: 'string error' }, + }), + ]); + }); + + test('allows omitted observer and ignores observer failures', async () => { + const original = new Error('original'); + + await expect(withAshibaRetry( + { + maxAttempts: 1, + retryOn: () => false, + }, + async () => { + throw original; + }, + )).rejects.toBe(original); + + await expect(withAshibaRetry( + { + maxAttempts: 1, + retryOn: () => false, + observer: { + emit() { + throw new Error('logger failed'); + }, + }, + }, + async () => { + throw original; + }, + )).rejects.toBe(original); + }); + + test('surfaces retryOn failures without dropping the operation failure', async () => { + const operationError = new Error('operation failed'); + const policyError = new Error('classifier failed'); + const events: AshibaRetryEvent[] = []; + + await expect(withAshibaRetry( + { + maxAttempts: 2, + retryOn() { + throw policyError; + }, + observer: { + emit(event) { + events.push(event); + }, + }, + }, + async () => { + throw operationError; + }, + )).rejects.toMatchObject({ + name: 'AshibaRetryPolicyError', + code: 'ASHIBA_RETRY_POLICY_INVALID', + cause: policyError, + details: { + operationError: { name: 'Error', message: 'operation failed' }, + policyError: { name: 'Error', message: 'classifier failed' }, + }, + }); + + expect(events).toEqual([ + expect.objectContaining({ + phase: 'give-up', + reason: 'retryOn threw while classifying the operation failure', + error: { name: 'Error', message: 'operation failed' }, + }), + ]); + }); + test('requires a visible retry policy', async () => { await expect(withAshibaRetry( { diff --git a/packages/driver-adapter-pg/src/index.ts b/packages/driver-adapter-pg/src/index.ts index 5796b5f..4bfa98b 100644 --- a/packages/driver-adapter-pg/src/index.ts +++ b/packages/driver-adapter-pg/src/index.ts @@ -158,7 +158,6 @@ const POSTGRES_TRANSIENT_SQLSTATES = new Set([ '08004', // sqlserver_rejected_establishment_of_sqlconnection '08006', // connection_failure '08007', // transaction_resolution_unknown - '08P01', // protocol_violation ]); const POSTGRES_TRANSIENT_NODE_ERROR_CODES = new Set([ diff --git a/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts b/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts index ad191e2..c19ce39 100644 --- a/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts +++ b/packages/driver-adapter-pg/tests/driver-adapter-pg.test.ts @@ -31,6 +31,10 @@ describe('@ashiba-ts/driver-adapter-pg', () => { retryable: false, code: '23505', }); + expect(classifyPostgresTransientError({ code: '08P01' })).toEqual({ + retryable: false, + code: '08P01', + }); expect(isPostgresTransientError({ code: '57P03' })).toBe(true); expect(isPostgresTransientError(new Error('plain'))).toBe(false); });