openvibers/openvibe.sdk Commits

SDK 0.49.1: the outbox rejects Events' 409 events.loop_detected instead of retrying it forever (#54)

OP OpenVibers committed 28 days ago · 1b4d6f2c5a · 8 files, +33 −8

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex dea784b..51360de 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,12 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.49.1 (2026-10-16)
++
++- Outbox: Events' `409 events.loop_detected` is a permanent refusal: the row is marked rejected instead of being
++ retried with backoff forever (Search retried 18 such events up to 35 times each after a Reviews loop was fixed).
++ Every other 409 is still retried.
++
## 0.49.0 (2026-10-15)
- `openvibe-sdk/download`: `createDownloadClient({ key })` for OpenVibe.Download (the owner's "file storage and backup"):
ddiff --git a/STATUS.json b/STATUS.json
iindex 842a7e5..7016fca 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.49.0",
++ "version": "0.49.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 6b197d7..386c560 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.49.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.49.1, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.49.0';
++const SDK_VERSION = '0.49.1';
module.exports = {
createClient,
ddiff --git a/package-lock.json b/package-lock.json
iindex 2f0c2db..2218d5c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.49.0",
++ "version": "0.49.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.49.0",
++ "version": "0.49.1",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex dbd2273..d375783 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.49.0",
++ "version": "0.49.1",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex ad2ab9e..cd3d087 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.49.0';
++const SDK_VERSION = '0.49.1';
module.exports = {
createClient,
ddiff --git a/src/outbox.js b/src/outbox.js
iindex 98820dd..960d969 100644
---- a/src/outbox.js
++++ b/src/outbox.js
@@@ -17,7 +17,9 @@
*
* Delivery is at least once: a crash between publish and mark-sent republishes the same event_id,
* which OpenVibe.Events answers as a duplicate. A 4xx other than 401/408/409/425/429 is permanent
-- * (the row is marked rejected and never retried); anything else is retried with backoff.
++ * (the row is marked rejected and never retried), and so is Events' 409 events.loop_detected (a trace that already
++ * carries too many hops of that type is refused for good: retrying it only repeats the refusal); anything else is
++ * retried with backoff.
*/
const TRACEPARENT_RE = /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/;
@@@ -44,6 +46,7 @@ function serialFlush(pass) {
function isPermanent(err) {
const s = err && err.status;
if (err && typeof err.url === 'string' && /\/oauth\/token(\?|$)/.test(err.url)) return false;
++ if (s === 409 && err.code === 'events.loop_detected') return true;
return Number.isInteger(s) && s >= 400 && s < 500 && !RETRYABLE_4XX.has(s);
}
ddiff --git a/test/pg-outbox.test.js b/test/pg-outbox.test.js
iindex f0a1bcb..3d10442 100644
---- a/test/pg-outbox.test.js
++++ b/test/pg-outbox.test.js
@@@ -93,6 +93,22 @@ function cases(label, open) {
assert.equal(await outbox.pending(), 1);
} finally { await done(); }
}],
++ [`${label}: Events' loop refusal (409 events.loop_detected) rejects the row; any other 409 is retried`, async () => {
++ const { db, done } = await open();
++ try {
++ const { events } = eventsClient();
++ // Like Events: a batch holding the looped event is refused as a loop, so the relay tries each row alone.
++ const looped = { prepare: events.prepare, publish: (env) => Promise.reject(Object.assign(new Error('loop'), { status: 409, code: (Array.isArray(env) ? env : [env]).some((e) => e.subject.id === 'loop') ? 'events.loop_detected' : 'events.conflict' })) };
++ const outbox = createPgOutbox(db, { events: looped });
++ await db.tx(async (t) => {
++ await outbox.enqueue(t, { event_type: 'live.stream.started', actor, subject: { type: 'stream', id: 'loop' } });
++ await outbox.enqueue(t, { event_type: 'live.stream.started', actor, subject: { type: 'stream', id: 'busy' } });
++ });
++ assert.deepEqual(await outbox.flush(), { sent: 0, failed: 1, rejected: 1 });
++ assert.equal(await outbox.rejected(), 1, 'the looped event is not retried');
++ assert.equal(await outbox.pending(), 1, 'the other conflict waits for its retry');
++ } finally { await done(); }
++ }],
[`${label}: a flush during a pass also publishes what was committed after that pass claimed`, async () => {
const { db, done } = await open();
try {