diff --git a/src/lib/relay-publish-filter.test.ts b/src/lib/relay-publish-filter.test.ts index 4a82718f..7544677b 100644 --- a/src/lib/relay-publish-filter.test.ts +++ b/src/lib/relay-publish-filter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { filterContextAuthorReadRelaysForPublish, filterRelaysForEventPublish, + isRelayPublishPolicyRejection, relayAllowsPublishKind } from './relay-publish-filter' @@ -38,4 +39,15 @@ describe('relay-publish-filter', () => { ]) expect(out).toEqual(['wss://relay.example.com/']) }) + + it('detects relay kind-policy rejections (not infrastructure faults)', () => { + expect( + isRelayPublishPolicyRejection( + 'only published longform articles accepted on this relay (kind 30023)' + ) + ).toBe(true) + expect(isRelayPublishPolicyRejection('this relay only accepts kind 1')).toBe(true) + expect(isRelayPublishPolicyRejection('Remote relay connection timeout')).toBe(false) + expect(isRelayPublishPolicyRejection('Publish timeout after 8000ms')).toBe(false) + }) }) diff --git a/src/lib/relay-publish-filter.ts b/src/lib/relay-publish-filter.ts index ae79df16..74865a24 100644 --- a/src/lib/relay-publish-filter.ts +++ b/src/lib/relay-publish-filter.ts @@ -50,6 +50,27 @@ export function filterRelaysForEventPublish(urls: readonly string[], eventKind: return urls.filter((u) => relayAllowsPublishKind(u, eventKind) && !isReadOnlyRelayUrl(u)) } +/** + * Relay refused the EVENT due to kind / content policy (not connectivity). + * These are expected when publishing to specialty relays — do not session-strike the relay. + */ +export function isRelayPublishPolicyRejection(message: string): boolean { + const m = message.trim().toLowerCase() + if (!m) return false + if (/\bkind\s*[:\s]?\s*\d+\b/.test(m) && /(accept|accepted|only|not supported|unsupported|reject|refused|allow|allowed|permitted)/.test(m)) { + return true + } + if (/only .{0,120} (accept|accepted|allowed|permitted)/.test(m)) return true + if (/(does not|don't|do not) accept/.test(m)) return true + if (/not accepted on this relay/.test(m)) return true + if (/wrong kind/.test(m)) return true + if (/unsupported kind/.test(m)) return true + if (/kind not (supported|allowed|permitted)/.test(m)) return true + if (/event kind (blocked|not allowed|rejected)/.test(m)) return true + if (/this relay only accepts/.test(m)) return true + return false +} + /** * Reply/mention author **read** hints used as publish targets: never LAN/Tor, read-only aggregators, * or profile/index mirrors (those are not inboxes for notes or reactions). diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index fb27722d..ad2894bd 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -96,6 +96,31 @@ describe('relaySessionStrikes.clearKey', () => { }) }) +describe('relaySessionStrikes publish failures', () => { + beforeEach(() => { + relaySessionStrikes.reset() + }) + + it('does not strike when relay rejects due to kind policy', () => { + const url = 'wss://essayist.decentnewsroom.com/' + for (let i = 0; i < 10; i++) { + relaySessionStrikes.recordPublishFailure( + url, + 'only published longform articles accepted on this relay (kind 30023)' + ) + } + expect(relaySessionStrikes.isPublishSkipped(url)).toBe(false) + }) + + it('strikes after repeated infrastructure publish failures', () => { + const url = 'wss://relay.example.com/' + relaySessionStrikes.recordPublishFailure(url, 'websocket closed') + const snap = relaySessionStrikes.getDebugSnapshot() + const entry = snap.entries.find((e) => e.key.includes('relay.example.com')) + expect(entry?.entry.publishFailures).toBe(1) + }) +}) + describe('isRelayStrikeEntryActive', () => { it('is false for empty entry', () => { expect( diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index cfb8f5d3..8957ffca 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -7,6 +7,7 @@ import { import type { Event } from 'nostr-tools' import { getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' +import { isRelayPublishPolicyRejection } from '@/lib/relay-publish-filter' import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch, isLocalNetworkUrl } from '@/lib/url' import type { RelayOpTerminalRow } from '@/services/relay-operation-log.service' @@ -288,7 +289,8 @@ class RelaySessionStrikes { return true } - recordPublishFailure(url: string): void { + recordPublishFailure(url: string, errorMessage?: string): void { + if (errorMessage && isRelayPublishPolicyRejection(errorMessage)) return const key = sessionKey(url) if (!key) return const now = Date.now() diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 0d055c1c..16ac9cc8 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1965,13 +1965,13 @@ class ClientService extends EventTarget { logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) errors.push({ url, error: authError }) relayStatuses.push({ url, success: false, error: authError.message }) - relaySessionStrikes.recordPublishFailure(url) + relaySessionStrikes.recordPublishFailure(url, authError.message) }) } else { logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) errors.push({ url, error }) relayStatuses.push({ url, success: false, error: error.message }) - relaySessionStrikes.recordPublishFailure(url) + relaySessionStrikes.recordPublishFailure(url, error.message) } }) @@ -2029,7 +2029,8 @@ class ClientService extends EventTarget { success: false, error: error instanceof Error ? error.message : 'Connection failed' }) - relaySessionStrikes.recordPublishFailure(url) + const errMsg = error instanceof Error ? error.message : 'Connection failed' + relaySessionStrikes.recordPublishFailure(url, errMsg) } finally { clearTimeout(relayTimeout) const currentFinished = ++finishedCount