libp2r2p 0.11.29 → 0.11.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1270,3 +1270,17 @@ and writes in groups of at most 100. Batched checks combine one recovery-type
1270
1270
  AND filter with coordinate OR filters, preserve input order and continue to
1271
1271
  decode and validate every returned snapshot. Custom adapters without batched
1272
1272
  methods retain the sequential fallback.
1273
+
1274
+ The 0.11.30 session archives each publication's recovery rows through one
1275
+ `putMany` call and treats pending journal rows as present in batched `hasMany`
1276
+ checks. Publication routing also keeps a runtime-only, per-messenger relay health
1277
+ circuit: explicit bans/refusals open immediately, repeated transport failures
1278
+ open after three attempts, and a fully quarantined route reports
1279
+ `RELAY_CIRCUIT_OPEN` with `retryAt` before another envelope is prepared. Reads and
1280
+ receive routing are unaffected. See [session relay fallback](private-messenger/session/README.md#relay-fallback).
1281
+
1282
+ The 0.11.31 pending-decryption retry groups records by
1283
+ `(receiverPubkey, ownContentPubkey)` for one pass. A FIFO probe may skip the
1284
+ remaining records only when the local signer confirms `CONTENT_KEY_NOT_FOUND`
1285
+ with `hasOwnContentKey: false`. MAC, proof, parsing and operational failures are
1286
+ never generalized to the group, and every deferred record remains persisted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.29",
3
+ "version": "0.11.31",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -24,10 +24,11 @@ without decrypting the archive; the store may also purge expired wrappers itself
24
24
  - Authorizations: the same operations plus `find(query)`, choosing the newest
25
25
  valid share, then the shorter expiry and lexicographically smaller record ID.
26
26
  - Batched methods preserve input order and process at most 100 coordinates per
27
- query. Identical inputs share one immutable result; expired inputs return
28
- `undefined` without a query or write. Batched checks use the recovery type
29
- as an AND filter and the record coordinates as an OR filter, then still decode
30
- and validate every returned snapshot.
27
+ query. Identical inputs share one immutable result. `putMany` returns
28
+ `undefined` for an expired input without querying or writing; `hasMany` returns
29
+ `false` for expired snapshots. Batched checks use the recovery type as an AND
30
+ filter and the record coordinates as an OR filter, then still decode and
31
+ validate every returned snapshot.
31
32
  - `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
32
33
  `receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
33
34
  `fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
@@ -132,7 +132,7 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
132
132
  const recordIds = rows.map(row => row.recordId || encodeRecord(type, row).tags[0][1])
133
133
  const present = new Set()
134
134
  for (const batch of chunks([...new Set(recordIds)])) {
135
- for await (const { row } of records(type, { recordIds: batch, includeExpired: true })) present.add(row.recordId)
135
+ for await (const { row } of records(type, { recordIds: batch })) present.add(row.recordId)
136
136
  }
137
137
  return recordIds.map(recordId => present.has(recordId))
138
138
  }
@@ -2,7 +2,7 @@ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
2
2
 
3
3
  // Each invocation owns one already signed outer event and its encrypted subset.
4
4
  // Lanes share coverage and the tried set; only started reports outlive the race.
5
- export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
5
+ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, rememberSuccess = () => {}, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
6
6
  const pending = new Set(receivers)
7
7
  const summaries = []
8
8
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
@@ -65,6 +65,7 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
65
65
  const settled = Promise.resolve(result.promise).catch(reason => ({ fulfilled: 0, errors: [{ reason }] }))
66
66
  slot.resolve(settled)
67
67
  settled.then(async report => {
68
+ rememberSuccess(report.succeededRelays || [])
68
69
  const failed = (report.errors || []).filter(item => isReplaceableRelayFailure(item.reason))
69
70
  // Late redundant failures can inform later sends after this race wins.
70
71
  if (failed.length && (result.success || (done && !pending.size)) && !transportSignal?.aborted && isCurrent()) {
@@ -0,0 +1,96 @@
1
+ import { normalizeRelayUrl } from '../../url/index.js'
2
+
3
+ const PERMANENT_PREFIXES = new Set(['banned', 'blocked', 'restricted', 'auth-required', 'pow'])
4
+ const TRANSIENT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
5
+ const RELAY_LIMIT = 256
6
+ const INITIAL_COOLDOWN_MS = 5 * 60 * 1000
7
+ const MAX_COOLDOWN_MS = 30 * 60 * 1000
8
+ const TIMEOUT_THRESHOLD = 3
9
+
10
+ const prefix = error => {
11
+ for (const value of [error?.message, error?.code]) {
12
+ const match = typeof value === 'string' && /^([a-z-]+):/.exec(value)
13
+ if (match) return match[1]
14
+ }
15
+ return ''
16
+ }
17
+ const relayUrl = value => {
18
+ try { return normalizeRelayUrl(value) } catch { return '' }
19
+ }
20
+ const cooldown = level => Math.min(MAX_COOLDOWN_MS, INITIAL_COOLDOWN_MS * 2 ** Math.max(0, level - 1))
21
+
22
+ // Publication-only, runtime relay health shared by one PrivateMessenger.
23
+ // Permanent refusals open immediately; transport failures open after three
24
+ // consecutive attempts. Expired entries become one half-open probe.
25
+ export function createPublicationRelayHealth ({ now = Date.now, limit = RELAY_LIMIT } = {}) {
26
+ const relays = new Map()
27
+
28
+ function save (relay, state) {
29
+ relays.delete(relay)
30
+ relays.set(relay, state)
31
+ while (relays.size > limit) relays.delete(relays.keys().next().value)
32
+ }
33
+
34
+ function isExcluded (relay, at = now()) {
35
+ relay = relayUrl(relay)
36
+ const state = relay ? relays.get(relay) : undefined
37
+ return Boolean(state?.until && state.until > at)
38
+ }
39
+
40
+ function retryAt (candidates, at = now()) {
41
+ let earliest = 0
42
+ for (const candidate of candidates || []) {
43
+ const relay = relayUrl(candidate)
44
+ const until = relay ? relays.get(relay)?.until : 0
45
+ if (until > at && (!earliest || until < earliest)) earliest = until
46
+ }
47
+ return earliest
48
+ }
49
+
50
+ function recordFailure (relay, reason, at = now()) {
51
+ relay = relayUrl(relay)
52
+ if (!relay) return 0
53
+ const previous = relays.get(relay) || { failures: 0, level: 0, until: 0 }
54
+ const halfOpen = previous.until > 0 && previous.until <= at
55
+ const permanent = PERMANENT_PREFIXES.has(prefix(reason))
56
+ const transient = TRANSIENT_CATEGORIES.has(reason?.category)
57
+ const retryAfter = Number.isSafeInteger(reason?.retryAt)
58
+ ? reason.retryAt
59
+ : Number.isSafeInteger(reason?.retryAfterMs)
60
+ ? at + Math.max(0, reason.retryAfterMs)
61
+ : 0
62
+ let next = previous
63
+ if (permanent || retryAfter > at) {
64
+ const level = halfOpen ? Math.min(previous.level + 1, 5) : Math.max(1, previous.level)
65
+ next = { failures: 0, level, until: Math.min(at + cooldown(level), retryAfter > at ? retryAfter : Infinity) }
66
+ } else if (transient || prefix(reason) === 'error') {
67
+ const failures = previous.failures + 1
68
+ if (halfOpen) {
69
+ const level = Math.min(previous.level + 1, 5)
70
+ next = { failures: 0, level, until: at + cooldown(level) }
71
+ } else if (failures >= TIMEOUT_THRESHOLD) {
72
+ next = { failures: 0, level: Math.max(1, previous.level), until: at + cooldown(Math.max(1, previous.level)) }
73
+ } else next = { failures, level: previous.level, until: 0 }
74
+ }
75
+ save(relay, next)
76
+ return next.until > at ? next.until : 0
77
+ }
78
+
79
+ function recordSuccess (relay) {
80
+ relay = relayUrl(relay)
81
+ if (relay) relays.delete(relay)
82
+ }
83
+
84
+ function clear () {
85
+ relays.clear()
86
+ }
87
+
88
+ return { isExcluded, retryAt, recordFailure, recordSuccess, clear }
89
+ }
90
+
91
+ export {
92
+ INITIAL_COOLDOWN_MS as PUBLICATION_RELAY_INITIAL_COOLDOWN_MS,
93
+ MAX_COOLDOWN_MS as PUBLICATION_RELAY_MAX_COOLDOWN_MS,
94
+ RELAY_LIMIT as PUBLICATION_RELAY_HEALTH_LIMIT,
95
+ TIMEOUT_THRESHOLD as PUBLICATION_TIMEOUT_THRESHOLD
96
+ }
@@ -68,6 +68,9 @@ export function sanitizePublicationError (error, context = {}) {
68
68
  ...(safeToken(source.reason) ? { reason: source.reason } : {}),
69
69
  ...(safeToken(source.eventId) ? { eventId: source.eventId } : {}),
70
70
  ...(Number.isSafeInteger(source.eventKind) ? { eventKind: source.eventKind } : {}),
71
+ ...(safeToken(source.category) ? { category: source.category } : {}),
72
+ ...(Number.isSafeInteger(source.retryAt) ? { retryAt: source.retryAt } : {}),
73
+ ...(Number.isSafeInteger(source.retryAfterMs) ? { retryAfterMs: source.retryAfterMs } : {}),
71
74
  ...(source.retryWhenAvailable === true ? { retryWhenAvailable: true } : {}),
72
75
  ...(source.retryWhenOnline === true ? { retryWhenOnline: true } : {}),
73
76
  reports: reports.map(sanitizedReport)
@@ -85,7 +88,8 @@ export function publicationFailureDiagnostic (error) {
85
88
  if (safeToken(value) && contextFields.includes(key)) result[key] = value
86
89
  else if (Number.isSafeInteger(value) && contextNumbers.includes(key)) result[key] = value
87
90
  }
88
- for (const key of ['code', 'reason', 'eventId']) if (safeToken(error?.[key])) result[key] = error[key]
91
+ for (const key of ['code', 'reason', 'eventId', 'category']) if (safeToken(error?.[key])) result[key] = error[key]
92
+ for (const key of ['retryAt', 'retryAfterMs']) if (Number.isSafeInteger(error?.[key])) result[key] = error[key]
89
93
  for (const key of ['retryWhenAvailable', 'retryWhenOnline']) if (error?.[key] === true) result[key] = true
90
94
  result.reports = (Array.isArray(error?.reports) ? error.reports : []).map(sanitizedReport)
91
95
  return result
@@ -8,6 +8,15 @@ const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { re
8
8
 
9
9
  export { isReplaceableRelayFailure }
10
10
 
11
+ export function relayCircuitError (retryAt) {
12
+ return Object.assign(new Error('RELAY_CIRCUIT_OPEN'), {
13
+ code: 'RELAY_CIRCUIT_OPEN',
14
+ category: 'timeout',
15
+ retryAt,
16
+ retryWhenAvailable: true
17
+ })
18
+ }
19
+
11
20
  export function normalizeFallbackRelays (value = []) {
12
21
  if (!Array.isArray(value)) throw new ValidationError('INVALID_FALLBACK_RELAYS')
13
22
  return [...new Set(Array.from(value, normalizeRelayUrl))]
@@ -32,11 +41,13 @@ function routeGroups (routes) {
32
41
  return [...groups.values()]
33
42
  }
34
43
 
35
- export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
44
+ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, relayHealth, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
36
45
  // Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
37
46
  // retain their fanout; configured fallbacks are a separate last-resort stage.
38
47
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
39
48
  if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
49
+ exclusions ??= new Map()
50
+ relayHealth ??= { isExcluded: () => false, retryAt: () => 0, recordFailure: () => 0, recordSuccess: () => {} }
40
51
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
41
52
  const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
42
53
  const primaryRoutes = primaryRelayToReceivers || (primaryRelays
@@ -49,12 +60,17 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
49
60
  const candidates = [...new Set([...Object.values(primaryByPeer).flatMap(value => value.read), ...fallbackRelays])]
50
61
  const candidateSet = new Set(candidates)
51
62
  for (const [relay, expires] of exclusions) if (expires <= now() || !candidateSet.has(relay)) exclusions.delete(relay)
52
- // A fresh attempt can recheck an exhausted list; each outer event still owns
53
- // a tried set so concurrent sends/fragments cannot create a retry loop.
54
- if (peers.some(pubkey => {
55
- const available = [...primaryByPeer[pubkey].read, ...fallbackRelays]
56
- return available.length && available.every(relay => exclusions.has(relay))
57
- })) exclusions.clear()
63
+ const unavailable = relay => exclusions.has(relay) || relayHealth.isExcluded(relay, now())
64
+ const initialExcluded = new Set(candidates.filter(unavailable))
65
+ const blockedPeers = peers.filter(pubkey => {
66
+ const available = [...primaryByPeer[pubkey]?.read || [], ...fallbackRelays]
67
+ return available.length > 0 && available.every(unavailable)
68
+ })
69
+ const circuitCandidates = blockedPeers.length
70
+ ? [...new Set(blockedPeers.flatMap(pubkey => [...primaryByPeer[pubkey]?.read || [], ...fallbackRelays]))]
71
+ : candidates
72
+ const circuitOpen = (candidates.length > 0 && candidates.every(relay => initialExcluded.has(relay))) || blockedPeers.length > 0
73
+ const circuitRetryAt = circuitOpen ? relayHealth.retryAt(circuitCandidates, now()) : 0
58
74
  const select = (receivers, excluded, staged = false) => {
59
75
  const excludedRelays = [...excluded]
60
76
  const excludeRelaysByPubkey = new Map(receivers.map(pubkey => [pubkey, excludedRelays]))
@@ -69,10 +85,20 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
69
85
  for (const [relay, pubkeys] of fallback) routes.set(relay, [...new Set([...(routes.get(relay) || []), ...pubkeys])])
70
86
  return routes
71
87
  }
72
- const relayToReceivers = primaryRelayToReceivers || select(peers, exclusions.keys())
88
+ const relayToReceivers = primaryRelayToReceivers || select(peers, initialExcluded)
73
89
  const current = () => !transportSignal?.aborted && isCurrent()
74
90
  const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
75
- const remember = errors => { for (const item of errors) if (candidateSet.has(normalized(item.relay))) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
91
+ const remember = errors => {
92
+ for (const item of errors) {
93
+ const relay = normalized(item.relay)
94
+ if (!candidateSet.has(relay)) continue
95
+ exclusions.set(relay, now() + EXCLUSION_MS)
96
+ relayHealth.recordFailure(relay, item.reason, now())
97
+ }
98
+ }
99
+ const rememberSuccess = relays => {
100
+ for (const relay of relays || []) relayHealth.recordSuccess(relay)
101
+ }
76
102
  const online = async () => {
77
103
  if (!current()) return false
78
104
  try { return await isOnline({ signal: transportSignal }) && current() } catch { return false }
@@ -85,11 +111,11 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
85
111
  const pending = new Set(receivers)
86
112
  const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
87
113
  const initialPrimary = context.primaryRelays || (primaryRelays || peers.length === 1 ? routeEntries(relayToReceivers).map(([relay]) => relay) : [])
88
- const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !exclusions.has(relay))
114
+ const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !unavailable(relay))
89
115
  if (!explicit && fallbackDelayMs !== null && fallbackRelays.length) {
90
116
  const tried = new Set()
91
117
  const pick = (pending, fallback) => {
92
- const excluded = [...new Set([...exclusions.keys(), ...tried])]
118
+ const excluded = [...new Set([...candidates.filter(unavailable), ...exclusions.keys(), ...tried])]
93
119
  const routes = pickRelays([...pending], fallback
94
120
  ? Object.fromEntries([...pending].map(pubkey => [pubkey, { read: fallbackRelays }]))
95
121
  : primaryByPeer, { relayType: 'read', maxPerPubkey: 2, excludeRelaysByPubkey: new Map([...pending].map(pubkey => [pubkey, excluded])), emptyRelaysFallback: [] })
@@ -98,17 +124,16 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
98
124
  const result = await publishWithEarlyFallback({
99
125
  event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
100
126
  nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
101
- sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
127
+ sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, rememberSuccess,
102
128
  delay: fallbackDelayMs, attemptStartedAt, monotonicNow, now, setTimer, clearTimer
103
129
  })
104
- if (!result.success && current()) exclusions.clear()
105
130
  return result
106
131
  }
107
132
  let batch = first.length ? { receivers, relays: first } : null
108
133
  const tried = new Set()
109
134
  const errors = []
110
135
  const summaries = []
111
- const next = () => routeGroups(select([...pending], new Set([...exclusions.keys(), ...tried]), true))[0]
136
+ const next = () => routeGroups(select([...pending], new Set([...candidates.filter(unavailable), ...exclusions.keys(), ...tried]), true))[0]
112
137
  const finish = result => ({
113
138
  ...result,
114
139
  success: summaries.length > 0 && pending.size === 0,
@@ -136,6 +161,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
136
161
  result = await sendEvent(event, relays, { signal: transportSignal })
137
162
  const settled = Promise.resolve(result.promise)
138
163
  summaries.push(settled)
164
+ settled.then(report => rememberSuccess(report?.succeededRelays || [])).catch(() => {})
139
165
  if (result.success) {
140
166
  for (const pubkey of batch.receivers) pending.delete(pubkey)
141
167
  // Keep the first ACK immediate; learn about redundant failures later.
@@ -148,10 +174,10 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
148
174
  const report = await settled
149
175
  const failed = rejected(report)
150
176
  errors.push(...(report?.errors || []))
177
+ if (failed.length) remember(failed)
151
178
  if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finished(result)
152
- if (!next()) { exclusions.clear(); return finished(result) }
179
+ if (!next()) return finished(result)
153
180
  if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: !signal?.aborted }
154
- remember(failed)
155
181
  }
156
182
  if (!current()) return finished(result)
157
183
  batch = null
@@ -161,8 +187,10 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
161
187
 
162
188
  return {
163
189
  relayToReceivers,
190
+ circuitOpen,
191
+ retryAt: circuitRetryAt,
164
192
  // A recovery mirror must not reintroduce refused relays or promote a fallback.
165
- recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(normalized(relay)) || !exclusions.has(normalized(relay))),
193
+ recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(normalized(relay)) || !unavailable(normalized(relay))),
166
194
  _publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
167
195
  }
168
196
  }
@@ -49,14 +49,15 @@ import { isOnline, onOnline } from '../network/index.js'
49
49
  import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
50
50
  import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
51
51
  import { backgroundSigner } from './helpers/background-signer.js'
52
- import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
52
+ import { createPublicationRelayHealth } from './helpers/publication-relay-health.js'
53
+ import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay, relayCircuitError } from './helpers/send-routing.js'
53
54
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
54
55
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
55
56
  import { assertMessagePublished, publicationFailureDiagnostic, sanitizePublicationError } from './helpers/publication.js'
56
57
  import { normalizeRelayUrl } from '../url/index.js'
57
58
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
58
59
  import { migrateMessageQueue } from './services/migrate-message-queue.js'
59
- import { createPendingDecryption } from './services/pending-decryption.js'
60
+ import { createPendingDecryption, pendingContentKeyGroup, pendingMissingContentKeyGroup } from './services/pending-decryption.js'
60
61
  import { createDecryptionScheduler } from './services/decryption-scheduler.js'
61
62
  import { decryptionError, isDeferredDecryptionError } from '../private-channel/helpers/decryption-error.js'
62
63
  import { bytesToBase64 } from '../base64/index.js'
@@ -319,9 +320,10 @@ export class PrivateMessenger {
319
320
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
320
321
  this._isOnline = _isOnline
321
322
  this._waitForForeground = _waitForForeground
322
- this.sendRelayExclusions = new Map()
323
+ this.publicationRelayHealth = createPublicationRelayHealth({ now: _now })
323
324
  this.sendRoutingLifetime = new AbortController()
324
325
  this.sendRoutingPause = new AbortController()
326
+ this._now = _now
325
327
  this._setTimeout = _setTimeout
326
328
  this._clearTimeout = _clearTimeout
327
329
  this._setInterval = _setInterval
@@ -800,7 +802,6 @@ export class PrivateMessenger {
800
802
  this.stopPresencePublisher(pubkey)
801
803
  }
802
804
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
803
- for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
804
805
  for (const channel of changed) this.channels.set(channel.pubkey, channel)
805
806
 
806
807
  // Incremental registration must not prune persisted channels whose signers
@@ -894,22 +895,16 @@ export class PrivateMessenger {
894
895
  async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug }) {
895
896
  if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
896
897
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
897
- if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
898
898
  const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
899
- if (fixed && !this.fallbackRelays.length) return { relays: fixed, recoveryRelays }
900
899
  const recipients = uniq(receiverPubkeys?.length ? receiverPubkeys : relayMapReceivers(relayToReceivers))
901
900
  const relaysByPubkey = fixed || relayToReceivers ? undefined : await this._getRelaysByPubkey(recipients)
902
- const key = `${channel.pubkey}:${[...recipients].sort().join(',')}`
903
- if (!this.sendRelayExclusions.has(key)) {
904
- if (this.sendRelayExclusions.size >= 256) this.sendRelayExclusions.delete(this.sendRelayExclusions.keys().next().value)
905
- this.sendRelayExclusions.set(key, { channelPubkey: channel.pubkey, exclusions: new Map() })
906
- }
907
901
  const routing = createSendRelayRouting({
908
902
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
909
903
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
910
- exclusions: this.sendRelayExclusions.get(key).exclusions,
904
+ exclusions: new Map(),
905
+ relayHealth: this.publicationRelayHealth,
911
906
  fallbackDelayMs: this.fallbackDelayMs, attemptStartedAt, pauseSignal: this.sendRoutingPause.signal,
912
- setTimer: this._setTimeout, clearTimer: this._clearTimeout,
907
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout, now: this._now,
913
908
  pickRelays: this._pickRelaysForPubkeys,
914
909
  publish: this._privateChannel.publish || privateChannel.publish,
915
910
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
@@ -922,10 +917,11 @@ export class PrivateMessenger {
922
917
  isOnline: this._isOnline,
923
918
  isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
924
919
  })
920
+ if (routing.circuitOpen) throw relayCircuitError(routing.retryAt)
925
921
  if (!relayMapRelays(routing.relayToReceivers).length) throw new ValidationError('NO_RELAYS')
926
922
  // Fixed lists address the original receiver set as a whole, preserving the
927
923
  // existing encryption grouping instead of inventing per-recipient routes.
928
- if (fixed) return { relays: fixed, recoveryRelays: routing.recoveryRelays, _publish: routing._publish }
924
+ if (fixed) return { relays: relayMapRelays(routing.relayToReceivers), recoveryRelays: routing.recoveryRelays, _publish: routing._publish }
929
925
  return routing
930
926
  }
931
927
 
@@ -3263,12 +3259,7 @@ export class PrivateMessenger {
3263
3259
  job.started = true
3264
3260
  const force = job.force
3265
3261
  job.force = false
3266
- const groups = new Map()
3267
- for (const record of await this.pendingDecryption.records({ force })) {
3268
- if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
3269
- groups.get(record.channelPubkey).push(record)
3270
- }
3271
- const results = await Promise.allSettled([...groups].map(async ([channelPubkey, records]) => {
3262
+ const replay = async (channelPubkey, records, callbacks = {}) => {
3272
3263
  const channel = this.channels.get(channelPubkey)
3273
3264
  if (!channel || !this.desiredChannels.has(channelPubkey)) return
3274
3265
  const current = () => {
@@ -3306,15 +3297,78 @@ export class PrivateMessenger {
3306
3297
  return this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }, { trackActivity: false })
3307
3298
  },
3308
3299
  onSeedEvent: seed => { current(); return this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed, { trackActivity: false }) },
3309
- onRecovered: record => { current(); return this.pendingDecryption.recovered(record) },
3310
- onFailure: (record, error) => { current(); return this.pendingDecryption.failed(record, error) }
3300
+ onRecovered: async record => {
3301
+ current()
3302
+ await callbacks.onRecovered?.(record)
3303
+ return this.pendingDecryption.recovered(record)
3304
+ },
3305
+ onFailure: async (record, error) => {
3306
+ current()
3307
+ if (await callbacks.onFailure?.(record, error) === false) return
3308
+ return this.pendingDecryption.failed(record, error)
3309
+ }
3311
3310
  })
3312
3311
  } catch (error) {
3313
3312
  if (this.channels.get(channelPubkey) === channel && this.desiredChannels.has(channelPubkey)) throw error
3314
3313
  } finally { this.decryptionScheduler.finish(channelPubkey) }
3315
- }))
3314
+ }
3315
+
3316
+ const records = await this.pendingDecryption.records({ force })
3317
+ const probes = new Map()
3318
+ for (const record of records) {
3319
+ const channelPubkey = record.channelPubkey
3320
+ if (!this.channels.has(channelPubkey) || !this.desiredChannels.has(channelPubkey)) continue
3321
+ const missingGroup = pendingMissingContentKeyGroup(record)
3322
+ if (missingGroup && !probes.has(missingGroup)) probes.set(missingGroup, record)
3323
+ }
3324
+
3325
+ const unavailableGroups = new Set()
3326
+ const probed = new Set()
3327
+ let probeError
3328
+ for (const [groupKey, candidate] of probes) {
3329
+ signal.throwIfAborted()
3330
+ const current = await this.pendingDecryption.current(candidate, { force })
3331
+ if (!current || pendingMissingContentKeyGroup(current) !== groupKey) continue
3332
+ probed.add(current.id)
3333
+ let missing = false
3334
+ await replay(current.channelPubkey, [current], {
3335
+ onFailure: async (_record, error) => {
3336
+ const failedGroup = pendingMissingContentKeyGroup({
3337
+ code: error.code,
3338
+ receiverPubkey: error.receiverPubkey,
3339
+ ownContentPubkey: error.ownContentPubkey,
3340
+ hasOwnContentKey: error.hasOwnContentKey
3341
+ })
3342
+ if (failedGroup === groupKey) missing = true
3343
+ if (isDeferredDecryptionError(error)) {
3344
+ await this.pendingDecryption.failed(current, error)
3345
+ return false
3346
+ }
3347
+ probeError ||= error
3348
+ return false
3349
+ }
3350
+ })
3351
+ if (missing) unavailableGroups.add(groupKey)
3352
+ }
3353
+
3354
+ const remaining = []
3355
+ for (const record of records) {
3356
+ if (!this.channels.has(record.channelPubkey) || !this.desiredChannels.has(record.channelPubkey)) continue
3357
+ if (probed.has(record.id)) continue
3358
+ const groupKey = pendingContentKeyGroup(record)
3359
+ if (groupKey && unavailableGroups.has(groupKey)) continue
3360
+ remaining.push(record)
3361
+ }
3362
+ const groups = new Map()
3363
+ for (const record of remaining) {
3364
+ if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
3365
+ groups.get(record.channelPubkey).push(record)
3366
+ }
3367
+ const results = await Promise.allSettled([...groups].map(([channelPubkey, channelRecords]) =>
3368
+ replay(channelPubkey, channelRecords)))
3316
3369
  const failed = results.find(result => result.status === 'rejected')
3317
3370
  if (failed) throw failed.reason
3371
+ if (probeError) throw probeError
3318
3372
  job.started = false
3319
3373
  } while (job.again)
3320
3374
  }
@@ -3384,7 +3438,7 @@ export class PrivateMessenger {
3384
3438
  if (this.closePromise) return this.closePromise
3385
3439
  const initSettledPromise = this.initSettledPromise
3386
3440
  this.sendRoutingLifetime.abort()
3387
- this.sendRelayExclusions.clear()
3441
+ this.publicationRelayHealth.clear()
3388
3442
  this.pendingStorageWrites.clear()
3389
3443
  for (const task of this.pauseRecoveries.values()) task.stop()
3390
3444
  for (const task of this.watchRecoveries.values()) task.stop()
@@ -6,6 +6,8 @@ import { isDeferredDecryptionError } from '../../private-channel/helpers/decrypt
6
6
 
7
7
  const encoder = new TextEncoder()
8
8
  const decoder = new TextDecoder()
9
+ const HEX64 = /^[0-9a-f]{64}$/i
10
+ const CONTENT_KEY_FIELDS = ['receiverPubkey', 'ownContentPubkey', 'peerContentPubkey']
9
11
  export const PENDING_DECRYPTION_MAX_BYTES = 16 * 1024 * 1024
10
12
 
11
13
  function storedBytes (item) {
@@ -30,6 +32,34 @@ function packetSnapshot (packet) {
30
32
  return { format: packet.format, channelPubkey: packet.channelPubkey, outer: event(packet.outer), ...(packet.router ? { router: event(packet.router, true) } : {}) }
31
33
  }
32
34
 
35
+ function errorMetadata (error) {
36
+ const metadata = {}
37
+ for (const key of CONTENT_KEY_FIELDS) {
38
+ const value = error?.[key]
39
+ if (typeof value === 'string' && HEX64.test(value)) metadata[key] = value.toLowerCase()
40
+ }
41
+ if (typeof error?.hasOwnContentKey === 'boolean') metadata.hasOwnContentKey = error.hasOwnContentKey
42
+ return metadata
43
+ }
44
+
45
+ function replaceErrorMetadata (item, error) {
46
+ const next = { ...item, code: error?.code }
47
+ for (const key of [...CONTENT_KEY_FIELDS, 'hasOwnContentKey']) delete next[key]
48
+ return Object.assign(next, errorMetadata(error))
49
+ }
50
+
51
+ export function pendingContentKeyGroup (item) {
52
+ const receiver = item?.receiverPubkey
53
+ const own = item?.ownContentPubkey
54
+ if (typeof receiver !== 'string' || typeof own !== 'string' || !HEX64.test(receiver) || !HEX64.test(own)) return ''
55
+ return `${receiver.toLowerCase()}:${own.toLowerCase()}`
56
+ }
57
+
58
+ export function pendingMissingContentKeyGroup (item) {
59
+ if (item?.code !== 'CONTENT_KEY_NOT_FOUND' || item?.hasOwnContentKey !== false) return ''
60
+ return pendingContentKeyGroup(item)
61
+ }
62
+
33
63
  // Stable across live, history and synthesized recovery wrappers.
34
64
  function packetId (record) {
35
65
  const rows = record.format === 'routerRow_v1'
@@ -72,8 +102,19 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
72
102
  record = packetSnapshot(record)
73
103
  return serial(async () => {
74
104
  const id = packetId(record)
75
- if (await queue.someBy('byId', id)) return true
76
- const item = { id, channelPubkey: record.channelPubkey, packet: structuredClone(record), code: error.code, macAttempts: error.code === 'INVALID_MAC' ? 1 : 0 }
105
+ const previous = await queue.getBy('byId', id)
106
+ if (previous) {
107
+ const updated = replaceErrorMetadata(previous, error)
108
+ if (JSON.stringify(updated) !== JSON.stringify(previous)) await queue.putBy('byId', updated, { existingOnly: true })
109
+ return true
110
+ }
111
+ const item = {
112
+ id,
113
+ channelPubkey: record.channelPubkey,
114
+ packet: structuredClone(record),
115
+ macAttempts: error.code === 'INVALID_MAC' ? 1 : 0,
116
+ ...replaceErrorMetadata({}, error)
117
+ }
77
118
  const requiredBytes = storedBytes(item)
78
119
  if (requiredBytes > maxBytes) {
79
120
  notify('discarded', { reason: 'oversized', channelPubkey: item.channelPubkey, id }); return true
@@ -115,7 +156,10 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
115
156
  if (!isDeferredDecryptionError(error)) throw error
116
157
  const current = await queue.getBy('byId', item.id)
117
158
  if (!current) return
118
- const updated = { ...current, code: error.code, macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0) }
159
+ const updated = {
160
+ ...replaceErrorMetadata(current, error),
161
+ macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0)
162
+ }
119
163
  await queue.putBy('byId', updated, { existingOnly: true })
120
164
  if (updated.macAttempts >= 3 && item.macAttempts < 3) notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey })
121
165
  })
@@ -9,6 +9,13 @@ the account messenger while available. Call after committed content-key changes;
9
9
  ordinary availability recovery already requests a pass. Ciphertext stays in the
10
10
  separate bounded FIFO until admitted to the normal inbox, evicted or explicitly
11
11
  cleared. This does not retry application storage/permission failures as crypto.
12
+ Within one pass, records whose last failure proved the same
13
+ `(receiverPubkey, ownContentPubkey)` is missing share one FIFO probe across all
14
+ channels. An unchanged `CONTENT_KEY_NOT_FOUND` skips only that group's remaining
15
+ records for the pass; none are removed or suspended. If the probe advances past
16
+ the key lookup, including with `INVALID_MAC`, or fails with another error, all
17
+ other records in the group are still attempted. Missing-key suppression never
18
+ survives into another pass.
12
19
 
13
20
  Construction accepts `pendingDecryptionReady` (default `true`). Pass `false` to
14
21
  hold pending queue reads/replay until `setPendingDecryptionReady(true)` releases
@@ -183,8 +190,8 @@ tag or deletion capability is recreated. Confirmed connectivity, exclusions and
183
190
  connection cooldowns remain authoritative; a waiting primary is not penalized
184
191
  merely because fallback started. Late native failures may inform later sends.
185
192
 
186
- On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
187
- rejections, or connection/transport/timeout failures, replacement requires
193
+ On `banned`, `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or
194
+ `error` rejections, or connection/transport/timeout failures, replacement requires
188
195
  `isOnline` to confirm connectivity. Invalid messages, local signer/authentication
189
196
  failures and unknown error text retain their original diagnosis.
190
197
 
@@ -204,10 +211,18 @@ members, every pending member must be covered before the outer event succeeds;
204
211
  a partial ACK cannot hide another member's failure. Remaining primary routes are
205
212
  exhausted before configured fallbacks with the default null delay or explicit
206
213
  routes. Redundant acknowledgements may update
207
- future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
208
- expire after five minutes, and never alter receive subscriptions. A fresh attempt
209
- can recheck an exhausted list. Additional fallback relays come only from the
210
- caller configuration; absent NIP-65 lists retain the existing discovery defaults.
214
+ future routing preferences without delaying a successfully covered send.
215
+ Publication health is shared by the messenger instance and never alters receive
216
+ subscriptions. Explicit bans/refusals open that relay immediately; connection,
217
+ transport and timeout failures open it after three consecutive attempts.
218
+ Cooldown starts at five minutes, doubles after failed half-open probes and caps
219
+ at 30 minutes. A success resets only that relay. When every route available to a
220
+ pending recipient is cooling down, publication returns
221
+ `RELAY_CIRCUIT_OPEN` with `retryAt` before preparing another encrypted envelope;
222
+ the range or outbox item remains pending and is retried no earlier than that
223
+ deadline. Health is bounded, runtime-only and cleared on close. Additional
224
+ fallback relays come only from the caller configuration; absent NIP-65 lists
225
+ retain the existing discovery defaults.
211
226
 
212
227
  When offline/interrupted with alternatives remaining, reports carry
213
228
  `retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
@@ -268,9 +283,11 @@ The existing `onPreparedSeed` callback now also supplies `publication`. The
268
283
  session commits that minimal journal before transmitting; direct messengers
269
284
  retain their local seed-store durability. Only after that commit can relay
270
285
  publication proceed. One independent serial worker converts recovery seeds into
271
- their configured archive. Pending durable rows participate in recovery reads
272
- immediately, including during a blocked archive write. No new seeds are enabled
273
- for modes which did not previously retain them.
286
+ their configured archive. Event-store adapters receive all rows for a publication
287
+ through one `putMany` call; serial `put` remains the external-adapter fallback.
288
+ Pending durable rows participate in recovery reads and batched `hasMany` checks
289
+ immediately, including during a blocked archive write. Expired rows are absent.
290
+ No new seeds are enabled for modes which did not previously retain them.
274
291
 
275
292
  First acceptance of every required envelope emits saved independently of archive
276
293
  or cleanup. ACK progress checkpoints serialize with other entry writes. A crash
@@ -130,6 +130,19 @@ export function pendingSeedMatches (row, query) {
130
130
  }
131
131
 
132
132
  export function withPendingSeeds (storage, pending) {
133
+ async function hasMany (rows) {
134
+ const pendingIds = new Set(pending().map(row => row.recordId).filter(Boolean))
135
+ const unresolved = rows.filter(row => !row.recordId || !pendingIds.has(row.recordId))
136
+ let persisted
137
+ if (typeof storage.hasMany === 'function') persisted = await storage.hasMany(unresolved)
138
+ else {
139
+ persisted = []
140
+ for (const row of unresolved) persisted.push(await storage.has(row))
141
+ }
142
+ if (!Array.isArray(persisted) || persisted.length !== unresolved.length) throw new Error('INVALID_RECOVERY_STORAGE_RESULT')
143
+ let index = 0
144
+ return rows.map(row => Boolean(row.recordId && pendingIds.has(row.recordId)) || Boolean(persisted[index++]))
145
+ }
133
146
  return {
134
147
  ...storage,
135
148
  async * iterate (query = {}) {
@@ -138,6 +151,7 @@ export function withPendingSeeds (storage, pending) {
138
151
  for (const row of pending()) if (pendingSeedMatches(row, query)) { seen.add(row.recordId); yield row }
139
152
  for await (const row of storage.iterate(query)) if (!seen.has(row.recordId)) yield row
140
153
  },
141
- has: row => pending().some(value => value.recordId === row.recordId) || storage.has(row)
154
+ hasMany,
155
+ async has (row) { return (await hasMany([row]))[0] }
142
156
  }
143
157
  }
@@ -153,6 +153,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
153
153
  let repump
154
154
  let draining
155
155
  let redrain = false
156
+ const sendRetryAt = new Map()
156
157
  const idleWaiters = new Set()
157
158
  const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
158
159
  const localComplete = entry => entry.localSaved.every(Boolean)
@@ -164,7 +165,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
164
165
  const journal = createPublicationJournal({
165
166
  entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
166
167
  archive: async (entry, rows) => {
167
- if (recoveryStorage?.seeds) { for (const row of rows) await recoveryStorage.seeds.put(row) } else if (entry.prepared.target.file) {
168
+ if (recoveryStorage?.seeds) {
169
+ if (typeof recoveryStorage.seeds.putMany === 'function') await recoveryStorage.seeds.putMany(rows)
170
+ else for (const row of rows) await recoveryStorage.seeds.put(row)
171
+ } else if (entry.prepared.target.file) {
168
172
  for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
169
173
  } else {
170
174
  for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
@@ -450,7 +454,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
450
454
  if (cleanups.has(entry.id)) return cleanups.get(entry.id)
451
455
  const startedAt = Date.now()
452
456
  const work = Promise.resolve().then(async () => { await journal.flush(entry); await storage.remove(entry.id) }).then(() => {
453
- entries.delete(entry.id); localRetries.delete(entry.id); attemptStarts.delete(entry.id)
457
+ entries.delete(entry.id); localRetries.delete(entry.id); attemptStarts.delete(entry.id); sendRetryAt.delete(entry.id)
454
458
  debug(entry, 'outbox-cleanup', startedAt)
455
459
  emit()
456
460
  }).catch(error => {
@@ -594,6 +598,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
594
598
  // The failed wire event may be a quote or file chunk. Report the
595
599
  // owning outbox item separately, without mutating the native error.
596
600
  if (error.retryWhenOnline) offlineSends.add(entry.id)
601
+ if (Number.isSafeInteger(error.retryAt)) sendRetryAt.set(entry.id, error.retryAt)
602
+ else sendRetryAt.delete(entry.id)
597
603
  if (entry.failed && (error.retryWhenAvailable || (entry.deliveryConfirmed && entry.retryable)) && !pausedSends.has(entry.id)) scheduleSendRetry()
598
604
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !entry.deliveryConfirmed && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
599
605
  }
@@ -614,14 +620,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
614
620
  const pendingRetryable = entry => Boolean(!cancelled.has(entry.id) && ((entry.localFailed && entry.localRetryable) || (entry.failed && (entry.retryable || entry.status === 'pending'))))
615
621
  function scheduleSendRetry () {
616
622
  if (closed || !available || sendRetryTimer) return
617
- const delay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * (0.8 + _random() * 0.4))
623
+ let nextRetryAt = 0
624
+ let unconstrained = false
625
+ for (const entry of entries.values()) {
626
+ if (!pendingRetryable(entry)) continue
627
+ const retryAt = sendRetryAt.get(entry.id)
628
+ if (!Number.isSafeInteger(retryAt)) { unconstrained = true; continue }
629
+ if (!nextRetryAt || retryAt < nextRetryAt) nextRetryAt = retryAt
630
+ }
631
+ if (unconstrained) nextRetryAt = 0
632
+ const backoff = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * (0.8 + _random() * 0.4))
633
+ const delay = Math.max(backoff, nextRetryAt ? nextRetryAt - Date.now() : 0)
618
634
  sendRetryDelay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * 2)
619
635
  sendRetryTimer = _setTimeout(() => {
620
636
  sendRetryTimer = undefined
621
637
  if (closed || !available) return
638
+ const now = Date.now()
622
639
  let retried = false
623
640
  for (const entry of entries.values()) {
624
641
  if (!pendingRetryable(entry)) continue
642
+ const retryAt = sendRetryAt.get(entry.id)
643
+ if (Number.isSafeInteger(retryAt) && retryAt > now) continue
644
+ sendRetryAt.delete(entry.id)
625
645
  if (entry.localFailed && entry.localRetryable) { localRetries.add(entry.id); retried = true }
626
646
  if (pausedSends.has(entry.id) || !entry.failed || !(entry.retryable || entry.status === 'pending')) continue
627
647
  entry.failed = false
@@ -630,6 +650,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
630
650
  retried = true
631
651
  }
632
652
  if (retried) { emit(); pump() }
653
+ if ([...entries.values()].some(entry => pendingRetryable(entry) && Number.isSafeInteger(sendRetryAt.get(entry.id)))) scheduleSendRetry()
633
654
  }, delay)
634
655
  sendRetryTimer.unref?.()
635
656
  }
@@ -921,7 +942,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
921
942
  },
922
943
  close () {
923
944
  return (closeWork ??= (async () => {
924
- closed = true; available = false; lifecycle++; localRetries.clear()
945
+ closed = true; available = false; lifecycle++; localRetries.clear(); sendRetryAt.clear()
925
946
  stopOnlineRetry(); offlineSends.clear(); networkUrgency.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
926
947
  clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
927
948
  for (const controller of sendControllers.values()) controller.abort()
@@ -1,6 +1,6 @@
1
1
  const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
2
2
  const RETRY_PREFIXES = new Set(['rate-limited', 'error'])
3
- const REPLACEMENT_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', ...RETRY_PREFIXES])
3
+ const REPLACEMENT_PREFIXES = new Set(['banned', 'blocked', 'restricted', 'auth-required', 'pow', ...RETRY_PREFIXES])
4
4
 
5
5
  // Retry and replacement are different policies: a refusal can justify another
6
6
  // relay without making the same operation retryable on the refusing relay.
@@ -8,7 +8,8 @@ function matches (error, prefixes) {
8
8
  if (error?.name === 'Nip42AuthenticationError') return false
9
9
  if (TRANSPORT_CATEGORIES.has(error?.category)) return true
10
10
  if (error?.category && error.category !== 'relay') return false
11
- return prefixes.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
11
+ const name = /^([a-z-]+):/.exec(error?.message || '')?.[1] || /^([a-z-]+):/.exec(error?.code || '')?.[1]
12
+ return prefixes.has(name)
12
13
  }
13
14
 
14
15
  export const isRetryableRelayFailure = error => matches(error, RETRY_PREFIXES)