libp2r2p 0.11.28 → 0.11.30

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
@@ -1264,3 +1264,17 @@ and range, plus report and relay classifications; arbitrary relay text is not
1264
1264
  logged. Native report causes remain available privately to recovery retry
1265
1265
  policy. Required publication coverage, first-ACK semantics and range recovery
1266
1266
  behavior are unchanged.
1267
+
1268
+ The 0.11.29 event-store recovery adapter batches immutable seed existence checks
1269
+ and writes in groups of at most 100. Batched checks combine one recovery-type
1270
+ AND filter with coordinate OR filters, preserve input order and continue to
1271
+ decode and validate every returned snapshot. Custom adapters without batched
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.28",
3
+ "version": "0.11.30",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -18,10 +18,17 @@ without decrypting the archive; the store may also purge expired wrappers itself
18
18
 
19
19
  ## Contracts and ownership
20
20
 
21
- - Seeds: `put(record)`, `iterate(query)`, `has(record)`, `removeLocal(query)`,
22
- `prune({ now })`, and `revoke(records)`.
21
+ - Seeds: `put(record)`, `putMany(records)`, `iterate(query)`, `has(record)`,
22
+ `hasMany(records)`, `removeLocal(query)`, `prune({ now })`, and
23
+ `revoke(records)`.
23
24
  - Authorizations: the same operations plus `find(query)`, choosing the newest
24
25
  valid share, then the shorter expiry and lexicographically smaller record ID.
26
+ - Batched methods preserve input order and process at most 100 coordinates per
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.
25
32
  - `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
26
33
  `receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
27
34
  `fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
@@ -6,6 +6,9 @@ import { encodeRecord, decodeRecord, selector, days, PREFIX } from './helpers/co
6
6
  export { encodeRecord as encodeRecoveryRecord, decodeRecord as decodeRecoveryRecord } from './helpers/codec.js'
7
7
 
8
8
  const nowSeconds = () => Math.floor(Date.now() / 1000)
9
+ const RECOVERY_BATCH_SIZE = 100
10
+ const chunks = values => Array.from({ length: Math.ceil(values.length / RECOVERY_BATCH_SIZE) }, (_, index) =>
11
+ values.slice(index * RECOVERY_BATCH_SIZE, (index + 1) * RECOVERY_BATCH_SIZE))
9
12
  function assertOptions (eventStore, signer) {
10
13
  if (!eventStore?.query || !eventStore?.addPersonalCopy || !signer?.getPublicKey || !signer?.obfuscate || !signer?.nip44v3?.decrypt) throw new ValidationError('INVALID_EVENT_STORE_OPTIONS')
11
14
  }
@@ -49,6 +52,11 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
49
52
  const [owner, context] = await ready()
50
53
  return { kinds: [1006], authors: [owner], '#k': ['30078'], '#c': [context], '#v': ['0', '1'], '&o': [await mirror('t', `${PREFIX}${type}`)] }
51
54
  }
55
+ async function exactFilterFor (type) {
56
+ check()
57
+ const [owner] = await ready()
58
+ return { kinds: [1006], authors: [owner], '&o': [await mirror('t', `${PREFIX}${type}`)] }
59
+ }
52
60
  async function read (wrapper) {
53
61
  check()
54
62
  // Query the store before every use; only decryption of an immutable wrapper
@@ -65,9 +73,13 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
65
73
  return { ...decoded, wrapper }
66
74
  }
67
75
  async function * records (type, query = {}) {
68
- const filter = await filterFor(type)
69
- if (query.recordId) filter['&o'].push(await mirror('d', query.recordId))
70
- else if (query.fileChannelPubkey && query.receiverPubkey) {
76
+ const exact = typeof query.recordId === 'string' || Array.isArray(query.recordIds)
77
+ const filter = exact ? await exactFilterFor(type) : await filterFor(type)
78
+ if (query.recordId) filter['#o'] = [await mirror('d', query.recordId)]
79
+ else if (Array.isArray(query.recordIds)) {
80
+ if (!query.recordIds.length) return
81
+ filter['#o'] = await Promise.all([...new Set(query.recordIds)].map(recordId => mirror('d', recordId)))
82
+ } else if (query.fileChannelPubkey && query.receiverPubkey) {
71
83
  const indices = type === 'seed' ? query.indices : [undefined]
72
84
  if (indices?.length) filter['#o'] = await Promise.all(indices.map(chunkIndex => mirror('s', selector(type, { ...query, chunkIndex }))))
73
85
  } else if (query.channelPubkey) {
@@ -92,24 +104,44 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
92
104
  }
93
105
  }
94
106
  function store (type) {
95
- return {
96
- async put (row) {
97
- check()
107
+ async function putMany (rows) {
108
+ check()
109
+ if (!Array.isArray(rows)) throw new ValidationError('INVALID_RECOVERY_RECORDS')
110
+ const encoded = rows.map(row => {
111
+ if (row.expiresAt <= now()) return null
98
112
  const event = encodeRecord(type, row)
99
- if (row.expiresAt <= now()) return
100
- const recordId = event.tags[0][1]
101
- const existing = records(type, { recordId, includeExpired: true })
102
- try { const first = await existing.next(); if (!first.done) return first.value.row } finally { await existing.return() }
103
- const saved = committed(await eventStore.addPersonalCopy(event, { context: '' }))
113
+ return { event, recordId: event.tags[0][1], row }
114
+ })
115
+ const unique = new Map(encoded.filter(Boolean).map(record => [record.recordId, record]))
116
+ const stored = new Map()
117
+ for (const recordIds of chunks([...unique.keys()])) {
118
+ for await (const { row } of records(type, { recordIds, includeExpired: true })) stored.set(row.recordId, row)
119
+ }
120
+ for (const [recordId, record] of unique) {
121
+ if (stored.has(recordId)) continue
122
+ const saved = committed(await eventStore.addPersonalCopy(record.event, { context: '' }))
104
123
  // A pending kind-5 tombstone can reject the insertion; do not cache it.
105
- if (saved.result.stored === false && saved.result.code === 'blocked') return
106
- return { ...row, recordId }
107
- },
124
+ if (saved.result.stored === false && saved.result.code === 'blocked') stored.set(recordId, undefined)
125
+ else stored.set(recordId, { ...record.row, recordId })
126
+ }
127
+ return encoded.map(record => record ? stored.get(record.recordId) : undefined)
128
+ }
129
+ async function hasMany (rows) {
130
+ check()
131
+ if (!Array.isArray(rows)) throw new ValidationError('INVALID_RECOVERY_RECORDS')
132
+ const recordIds = rows.map(row => row.recordId || encodeRecord(type, row).tags[0][1])
133
+ const present = new Set()
134
+ for (const batch of chunks([...new Set(recordIds)])) {
135
+ for await (const { row } of records(type, { recordIds: batch })) present.add(row.recordId)
136
+ }
137
+ return recordIds.map(recordId => present.has(recordId))
138
+ }
139
+ return {
140
+ async put (row) { return (await putMany([row]))[0] },
141
+ putMany,
108
142
  async * iterate (query = {}) { for await (const { row } of records(type, query)) yield row },
109
- async has (row) {
110
- const iterator = records(type, { recordId: row.recordId || encodeRecord(type, row).tags[0][1] })
111
- try { return !(await iterator.next()).done } finally { await iterator.return() }
112
- },
143
+ async has (row) { return (await hasMany([row]))[0] },
144
+ hasMany,
113
145
  async removeLocal (query = {}) {
114
146
  for await (const { wrapper } of records(type, { ...query, includeExpired: true })) await eventStore.remove([['e', wrapper.id]])
115
147
  },
@@ -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,7 +49,8 @@ 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'
@@ -106,6 +107,7 @@ export {
106
107
 
107
108
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
108
109
  const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
110
+ const RECOVERY_STORAGE_BATCH_SIZE = 100
109
111
  const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
110
112
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
111
113
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
@@ -318,9 +320,10 @@ export class PrivateMessenger {
318
320
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
319
321
  this._isOnline = _isOnline
320
322
  this._waitForForeground = _waitForForeground
321
- this.sendRelayExclusions = new Map()
323
+ this.publicationRelayHealth = createPublicationRelayHealth({ now: _now })
322
324
  this.sendRoutingLifetime = new AbortController()
323
325
  this.sendRoutingPause = new AbortController()
326
+ this._now = _now
324
327
  this._setTimeout = _setTimeout
325
328
  this._clearTimeout = _clearTimeout
326
329
  this._setInterval = _setInterval
@@ -799,7 +802,6 @@ export class PrivateMessenger {
799
802
  this.stopPresencePublisher(pubkey)
800
803
  }
801
804
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
802
- for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
803
805
  for (const channel of changed) this.channels.set(channel.pubkey, channel)
804
806
 
805
807
  // Incremental registration must not prune persisted channels whose signers
@@ -893,22 +895,16 @@ export class PrivateMessenger {
893
895
  async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug }) {
894
896
  if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
895
897
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
896
- if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
897
898
  const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
898
- if (fixed && !this.fallbackRelays.length) return { relays: fixed, recoveryRelays }
899
899
  const recipients = uniq(receiverPubkeys?.length ? receiverPubkeys : relayMapReceivers(relayToReceivers))
900
900
  const relaysByPubkey = fixed || relayToReceivers ? undefined : await this._getRelaysByPubkey(recipients)
901
- const key = `${channel.pubkey}:${[...recipients].sort().join(',')}`
902
- if (!this.sendRelayExclusions.has(key)) {
903
- if (this.sendRelayExclusions.size >= 256) this.sendRelayExclusions.delete(this.sendRelayExclusions.keys().next().value)
904
- this.sendRelayExclusions.set(key, { channelPubkey: channel.pubkey, exclusions: new Map() })
905
- }
906
901
  const routing = createSendRelayRouting({
907
902
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
908
903
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
909
- exclusions: this.sendRelayExclusions.get(key).exclusions,
904
+ exclusions: new Map(),
905
+ relayHealth: this.publicationRelayHealth,
910
906
  fallbackDelayMs: this.fallbackDelayMs, attemptStartedAt, pauseSignal: this.sendRoutingPause.signal,
911
- setTimer: this._setTimeout, clearTimer: this._clearTimeout,
907
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout, now: this._now,
912
908
  pickRelays: this._pickRelaysForPubkeys,
913
909
  publish: this._privateChannel.publish || privateChannel.publish,
914
910
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
@@ -921,10 +917,11 @@ export class PrivateMessenger {
921
917
  isOnline: this._isOnline,
922
918
  isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
923
919
  })
920
+ if (routing.circuitOpen) throw relayCircuitError(routing.retryAt)
924
921
  if (!relayMapRelays(routing.relayToReceivers).length) throw new ValidationError('NO_RELAYS')
925
922
  // Fixed lists address the original receiver set as a whole, preserving the
926
923
  // existing encryption grouping instead of inventing per-recipient routes.
927
- 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 }
928
925
  return routing
929
926
  }
930
927
 
@@ -2148,9 +2145,13 @@ export class PrivateMessenger {
2148
2145
  const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
2149
2146
  ? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
2150
2147
  : compactSeedRouterRows(seed)
2151
- for (const row of rows) {
2148
+ const records = rows.map(row => {
2152
2149
  const time = seedRecordTime(row) || receivedAt
2153
- await this.seedStorage.put({ ...row, channelPubkey, receivedAt, expiresAt: time + this.offlineRecoverySecondsFor(channelPubkey) })
2150
+ return { ...row, channelPubkey, receivedAt, expiresAt: time + this.offlineRecoverySecondsFor(channelPubkey) }
2151
+ })
2152
+ if (typeof this.seedStorage.putMany === 'function') await this.seedStorage.putMany(records)
2153
+ else {
2154
+ for (const row of records) await this.seedStorage.put(row)
2154
2155
  }
2155
2156
  return
2156
2157
  }
@@ -2799,6 +2800,16 @@ export class PrivateMessenger {
2799
2800
  if (this.offlineRecoverySecondsFor(channelPubkey)) {
2800
2801
  const seen = new Set()
2801
2802
  const key = seed => seed.router ? routerSeedRowKey(seed) : nymCarrierSeedKey(seed)
2803
+ const sendStored = async seeds => {
2804
+ let present
2805
+ if (typeof this.seedStorage?.hasMany === 'function') present = await this.seedStorage.hasMany(seeds)
2806
+ else {
2807
+ present = []
2808
+ for (const seed of seeds) present.push(await this.seedStorage.has(seed))
2809
+ }
2810
+ if (!Array.isArray(present) || present.length !== seeds.length) throw new ValidationError('INVALID_RECOVERY_STORAGE_RESULT')
2811
+ for (let index = 0; index < seeds.length; index++) if (present[index]) await packer.update(seeds[index])
2812
+ }
2802
2813
  const pending = this._pendingSeedRecords({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until })
2803
2814
  for (const seed of pending) {
2804
2815
  if (seed.channelPubkey !== channelPubkey || (seed.receiverPubkey && seed.receiverPubkey !== message.event?.pubkey) || seed.expiresAt <= nowSeconds()) continue
@@ -2806,11 +2817,17 @@ export class PrivateMessenger {
2806
2817
  if (until !== undefined && (seed.firstSeenAt || seed.router.created_at) > until) continue
2807
2818
  seen.add(key(seed)); await packer.update(seed)
2808
2819
  }
2820
+ const stored = []
2809
2821
  for await (const seed of this.seedStorage ? this.seedStorage.iterate({ channelPubkey, receiverPubkey: message.event?.pubkey, since, until }) : this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
2810
2822
  if (seen.has(key(seed)) || seedRecordTime(seed) < nowSeconds() - this.offlineRecoverySecondsFor(channelPubkey)) continue
2811
- if (this.seedStorage && !await this.seedStorage.has(seed)) continue
2812
- await packer.update(seed)
2823
+ if (!this.seedStorage) {
2824
+ await packer.update(seed)
2825
+ continue
2826
+ }
2827
+ stored.push(seed)
2828
+ if (stored.length === RECOVERY_STORAGE_BATCH_SIZE) await sendStored(stored.splice(0))
2813
2829
  }
2830
+ if (stored.length) await sendStored(stored)
2814
2831
  }
2815
2832
  await packer.finalize()
2816
2833
  }
@@ -3363,7 +3380,7 @@ export class PrivateMessenger {
3363
3380
  if (this.closePromise) return this.closePromise
3364
3381
  const initSettledPromise = this.initSettledPromise
3365
3382
  this.sendRoutingLifetime.abort()
3366
- this.sendRelayExclusions.clear()
3383
+ this.publicationRelayHealth.clear()
3367
3384
  this.pendingStorageWrites.clear()
3368
3385
  for (const task of this.pauseRecoveries.values()) task.stop()
3369
3386
  for (const task of this.watchRecoveries.values()) task.stop()
@@ -183,8 +183,8 @@ tag or deletion capability is recreated. Confirmed connectivity, exclusions and
183
183
  connection cooldowns remain authoritative; a waiting primary is not penalized
184
184
  merely because fallback started. Late native failures may inform later sends.
185
185
 
186
- On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
187
- rejections, or connection/transport/timeout failures, replacement requires
186
+ On `banned`, `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or
187
+ `error` rejections, or connection/transport/timeout failures, replacement requires
188
188
  `isOnline` to confirm connectivity. Invalid messages, local signer/authentication
189
189
  failures and unknown error text retain their original diagnosis.
190
190
 
@@ -204,10 +204,18 @@ members, every pending member must be covered before the outer event succeeds;
204
204
  a partial ACK cannot hide another member's failure. Remaining primary routes are
205
205
  exhausted before configured fallbacks with the default null delay or explicit
206
206
  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.
207
+ future routing preferences without delaying a successfully covered send.
208
+ Publication health is shared by the messenger instance and never alters receive
209
+ subscriptions. Explicit bans/refusals open that relay immediately; connection,
210
+ transport and timeout failures open it after three consecutive attempts.
211
+ Cooldown starts at five minutes, doubles after failed half-open probes and caps
212
+ at 30 minutes. A success resets only that relay. When every route available to a
213
+ pending recipient is cooling down, publication returns
214
+ `RELAY_CIRCUIT_OPEN` with `retryAt` before preparing another encrypted envelope;
215
+ the range or outbox item remains pending and is retried no earlier than that
216
+ deadline. Health is bounded, runtime-only and cleared on close. Additional
217
+ fallback relays come only from the caller configuration; absent NIP-65 lists
218
+ retain the existing discovery defaults.
211
219
 
212
220
  When offline/interrupted with alternatives remaining, reports carry
213
221
  `retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
@@ -268,9 +276,11 @@ The existing `onPreparedSeed` callback now also supplies `publication`. The
268
276
  session commits that minimal journal before transmitting; direct messengers
269
277
  retain their local seed-store durability. Only after that commit can relay
270
278
  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.
279
+ their configured archive. Event-store adapters receive all rows for a publication
280
+ through one `putMany` call; serial `put` remains the external-adapter fallback.
281
+ Pending durable rows participate in recovery reads and batched `hasMany` checks
282
+ immediately, including during a blocked archive write. Expired rows are absent.
283
+ No new seeds are enabled for modes which did not previously retain them.
274
284
 
275
285
  First acceptance of every required envelope emits saved independently of archive
276
286
  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)