diff --git a/src/storage/version/put.js b/src/storage/version/put.js index a4716a3a..5c939801 100644 --- a/src/storage/version/put.js +++ b/src/storage/version/put.js @@ -99,6 +99,8 @@ function buildInput({ }; } +const MAX_PUT_ATTEMPTS = 5; + export async function putObjectWithVersion( env, daCtx, @@ -106,6 +108,7 @@ export async function putObjectWithVersion( body, guid, clientConditionals = null, + putAttempt = 0, ) { const config = getS3Config(env); const current = await getObject(env, update, false); @@ -186,7 +189,7 @@ export async function putObjectWithVersion( const status = e.$metadata?.httpStatusCode || 500; if (status === 412) { // Only retry if no client conditionals (internal operation) and under retry limit - if (!effectiveConditionals?.ifNoneMatch) { + if (!effectiveConditionals?.ifNoneMatch && putAttempt < MAX_PUT_ATTEMPTS) { return putObjectWithVersion( env, daCtx, @@ -194,6 +197,7 @@ export async function putObjectWithVersion( body, guid, clientConditionals, + putAttempt + 1, ); } // Client conditional failed or max retries exceeded, return 412 @@ -311,8 +315,16 @@ export async function putObjectWithVersion( // A specific ETag means the client explicitly requires that version, so propagate 412. const shouldRetry = !effectiveConditionals?.ifMatch || effectiveConditionals.ifMatch === '*'; - if (shouldRetry) { - return putObjectWithVersion(env, daCtx, update, body, guid, clientConditionals); + if (shouldRetry && putAttempt < MAX_PUT_ATTEMPTS) { + return putObjectWithVersion( + env, + daCtx, + update, + body, + guid, + clientConditionals, + putAttempt + 1, + ); } return { status: 412, metadata: { id: ID } }; } diff --git a/test/storage/version/put.test.js b/test/storage/version/put.test.js index 0e675333..3b7e9a13 100644 --- a/test/storage/version/put.test.js +++ b/test/storage/version/put.test.js @@ -311,6 +311,72 @@ describe('Version Put', () => { assert.equal(1, sendCalls.length, 'Should not retry when client sent a specific ETag'); }); + it('putObjectWithVersion stops retrying new document after MAX_PUT_ATTEMPTS', async () => { + const getObjectCalls = []; + const mockGetObject = async (e, u, nb) => { + getObjectCalls.push(1); + return { status: 404, metadata: {} }; + }; + + const sendCalls = []; + const mockS3Client = { + async send(cmd) { + sendCalls.push(cmd); + const err = new Error('PreconditionFailed'); + err.$metadata = { httpStatusCode: 412 }; + throw err; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { default: mockGetObject }, + '../../../src/storage/utils/version.js': { ifNoneMatch: () => mockS3Client }, + }); + + const resp = await putObjectWithVersion({}, { users: [] }, {}, false); + + assert.equal(412, resp.status); + assert.equal(6, sendCalls.length, 'Should attempt exactly MAX_PUT_ATTEMPTS+1 (5+1) times'); + assert.equal(6, getObjectCalls.length, 'Should re-fetch object on each retry'); + }); + + it('putObjectWithVersion stops retrying existing document after MAX_PUT_ATTEMPTS', async () => { + const getObjectCalls = []; + const mockGetObject = async () => { + getObjectCalls.push(1); + return { status: 200, metadata: {}, etag: 'etag-x' }; + }; + + const sendCalls = []; + const mockIfMatchClient = { + async send(cmd) { + sendCalls.push(cmd); + const err = new Error('PreconditionFailed'); + err.$metadata = { httpStatusCode: 412 }; + throw err; + }, + }; + const mockIfNoneMatchClient = { + async send() { + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { default: mockGetObject }, + '../../../src/storage/utils/version.js': { + ifMatch: () => mockIfMatchClient, + ifNoneMatch: () => mockIfNoneMatchClient, + }, + }); + + const resp = await putObjectWithVersion({}, { users: [] }, {}, false); + + assert.equal(412, resp.status); + assert.equal(6, sendCalls.length, 'Should attempt exactly MAX_PUT_ATTEMPTS+1 (5+1) times'); + assert.equal(6, getObjectCalls.length, 'Should re-fetch object on each retry'); + }); + it('Put Object With Version store content', async () => { // eslint-disable-next-line consistent-return const mockGetObject = async (e, u, h) => {