From 6a3114e1ba073faf65b09b40f152b9b30348d767 Mon Sep 17 00:00:00 2001 From: "Bill Gates (ops agent)" Date: Fri, 18 Sep 2026 02:43:54 +0000 Subject: [PATCH 1/3] feat(chunks): record why a chunk POST failed and whether peers will keep it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps made a real peer problem undiagnosable from the gateway. 1. arweave_chunk_post_total{status="fail"} carried no reason, so a peer rejecting chunks (400), one rate-limiting us (429) and one we could not reach were indistinguishable. Diagnosing a peer that was failing ~25% of our posts took that operator tracing their own node and telling us the answer; the label would have shown it directly. Arweave nodes rate-limit chunk uploads per source IP unless the sender is in their local_peers, so 429 in particular is an operator action, not a peer defect. `reason` is now set on failures only (empty for successes): the peer's HTTP status as a string when it answered, otherwise timeout / canceled / network. The dry-run validation failures report invalid_chunk and invalid_proof. 2. A 303 ("temporary") is counted as a success, which is right — the peer stored the chunk in its disk pool and that is still propagation, and a chunk whose transaction is still pending has no absolute offset yet, so 303 is the expected answer even from tip nodes. But callers could not see the split, and it matters: on a production gateway 56% of successful posts over 24h were 303, and for peers that cover no storage module for the offset those chunks are dropped when the pool matures. broadcastChunk now reports temporarySuccessCount and longTermSuccessCount (derived from the results array, not the deliberately racy early-exit counters) and sets both as span attributes. No behaviour change: nothing is posted, retried or thresholded differently. Tests: failure reason labels for an answered rejection (429) and an unanswered post (timeout); the long-term/temporary split across mixed 200/303 peers and an all-303 broadcast. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012NWDKc9pST69qTEha4AGaB --- src/arweave/composite-client.test.ts | 83 ++++++++++++++++++++ src/arweave/composite-client.ts | 45 ++++++++++- src/data/rebroadcasting-chunk-source.test.ts | 13 +++ src/metrics.ts | 10 ++- src/types.d.ts | 14 ++++ 5 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/arweave/composite-client.test.ts b/src/arweave/composite-client.test.ts index 3a771fdcf..50a5fbc0f 100644 --- a/src/arweave/composite-client.test.ts +++ b/src/arweave/composite-client.test.ts @@ -15,6 +15,7 @@ import { toB64Url } from '../lib/encoding.js'; import { UniformFailureSimulator } from '../lib/chaos.js'; import { ArweavePeerManager } from '../peers/arweave-peer-manager.js'; import * as config from '../config.js'; +import * as metrics from '../metrics.js'; import log from '../log.js'; describe('ArweaveCompositeClient', () => { @@ -529,6 +530,48 @@ describe('ArweaveCompositeClient', () => { assert.equal(result.success, false); assert.equal(result.statusCode, 500); }); + + // The failure counter is what an operator reads when a peer starts + // rejecting chunks. Without a reason, a peer refusing the chunk (400), one + // rate-limiting us (429) and one we cannot reach are indistinguishable, + // and each calls for a different response. + const failReasonCount = async (endpoint: string, reason: string) => { + const { values } = await metrics.arweaveChunkPostCounter.get(); + const sample = values.find( + (v: any) => + v.labels.endpoint === endpoint && + v.labels.status === 'fail' && + v.labels.reason === reason, + ); + return sample?.value ?? 0; + }; + + it('labels a peer-rejected post with the peer’s status code', async () => { + respond = (res) => res.writeHead(429).end(); + const client = createTestClient(); + const before = await failReasonCount(baseUrl, '429'); + await post(client); + assert.equal(await failReasonCount(baseUrl, '429'), before + 1); + }); + + it('labels a post the peer never answers as a timeout, not a status code', async () => { + // Never respond: the request must hit responseTimeout rather than any + // HTTP status, so the reason has to come from the error, not a response. + respond = () => undefined; + const client: any = createTestClient(); + const before = await failReasonCount(baseUrl, 'timeout'); + const result = await client.postChunkToPeer({ + peer: baseUrl, + chunk: {} as any, + abortTimeout: 5000, + responseTimeout: 50, + headers: {}, + }); + assert.equal(result.success, false); + assert.equal(result.timedOut, true); + assert.equal(result.statusCode, undefined); + assert.equal(await failReasonCount(baseUrl, 'timeout'), before + 1); + }); }); // Verifies the CHUNK_POST_CONTINUE_PAST_THRESHOLD behavior against real @@ -626,6 +669,46 @@ describe('ArweaveCompositeClient', () => { assert.equal(result.successCount, urls.length); }); + // successCount alone cannot tell "peers that will keep this chunk" from + // "peers that parked it in a disk pool they will drain", because a 303 is + // counted as a success (correctly — it is still propagation). The split is + // reported so callers can see which they got. + it('splits successes into long-term (200) and temporary (303)', async () => { + await startServers(2, 200); + const longTerm = [...urls]; + // startServers resets `servers`/`urls`, so hold on to the first batch and + // restore both lists afterwards — otherwise afterEach never closes those + // listeners and this file leaks handles. + const longTermServers = [...servers]; + await startServers(3, 303); + const temporary = [...urls]; + servers = [...longTermServers, ...servers]; + urls = [...longTerm, ...temporary]; + mockPeerManager.getPeerUrls = mock.fn(() => urls); + mockPeerManager.selectPeers = mock.fn(() => urls); + + const client = createTestClient(); + const result = await broadcast(client, true); + + assert.equal(result.successCount, urls.length); + assert.equal(result.longTermSuccessCount, longTerm.length); + assert.equal(result.temporarySuccessCount, temporary.length); + assert.equal( + result.longTermSuccessCount + result.temporarySuccessCount, + result.successCount, + ); + }); + + it('counts a broadcast accepted only into disk pools as entirely temporary', async () => { + await startServers(4, 303); + const client = createTestClient(); + const result = await broadcast(client, true); + + assert.equal(result.successCount, urls.length); + assert.equal(result.temporarySuccessCount, urls.length); + assert.equal(result.longTermSuccessCount, 0); + }); + it('bails out of the dead peer tail in continuePastThreshold mode', async () => { const deadUrls = await makeDeadUrls(25); // Three live peers first, then a long dead tail. diff --git a/src/arweave/composite-client.ts b/src/arweave/composite-client.ts index 8459e2758..72f06daad 100644 --- a/src/arweave/composite-client.ts +++ b/src/arweave/composite-client.ts @@ -548,6 +548,7 @@ export class ArweaveCompositeClient metrics.arweaveChunkPostCounter.inc({ endpoint: task.peer, status: 'fail', + reason: 'invalid_chunk', }); return { @@ -600,6 +601,7 @@ export class ArweaveCompositeClient metrics.arweaveChunkPostCounter.inc({ endpoint: task.peer, status: 'fail', + reason: 'invalid_proof', }); return { @@ -662,19 +664,35 @@ export class ArweaveCompositeClient canceled = error.code === 'ERR_CANCELED'; } + // A peer that answered tells us why it refused the chunk (400 unknown + // data root, 429 rate limited, 503 overloaded); one that did not answer + // is a timeout, our own abort, or unreachable. These call for completely + // different operator responses, so record which it was. + const statusCode = error.response?.status; + const reason = + statusCode !== undefined + ? String(statusCode) + : timedOut + ? 'timeout' + : canceled + ? 'canceled' + : 'network'; + metrics.arweaveChunkPostCounter.inc({ endpoint: task.peer, status: 'fail', + reason, }); this.log.debug('Failed to POST chunk to peer:', { peer: task.peer, error: error.message, + reason, }); return { success: false, - statusCode: error.response?.status, + statusCode, error: error.message, canceled, timedOut, @@ -1965,6 +1983,8 @@ export class ArweaveCompositeClient return { successCount: 0, preferredSuccessCount: 0, + temporarySuccessCount: 0, + longTermSuccessCount: 0, failureCount: 0, results: [], }; @@ -2158,6 +2178,7 @@ export class ArweaveCompositeClient statusCode, canceled: result.canceled ?? false, timedOut: result.timedOut ?? false, + temporary: result.temporary ?? false, }; } catch (error: any) { failureCount++; @@ -2192,10 +2213,28 @@ export class ArweaveCompositeClient } } + // Derived from `results` rather than incremented in the workers: the + // counters above are deliberately racy (they only gate early + // termination), while these are reported to callers. + const temporarySuccessCount = results.filter( + (r) => r.success && r.temporary === true, + ).length; + const longTermSuccessCount = results.filter( + (r) => r.success && r.temporary !== true, + ).length; + const duration = Date.now() - startTime; span.setAttribute('chunk.broadcast.duration_ms', duration); span.setAttribute('chunk.broadcast.success_count', successCount); + span.setAttribute( + 'chunk.broadcast.temporary_success_count', + temporarySuccessCount, + ); + span.setAttribute( + 'chunk.broadcast.long_term_success_count', + longTermSuccessCount, + ); span.setAttribute( 'chunk.broadcast.preferred_success_count', preferredSuccessCount, @@ -2243,6 +2282,8 @@ export class ArweaveCompositeClient this.log.debug('Chunk broadcast complete', { successCount, preferredSuccessCount, + temporarySuccessCount, + longTermSuccessCount, failureCount, consecutive4xxFailures, totalPeers: sortedPeers.length, @@ -2262,6 +2303,8 @@ export class ArweaveCompositeClient return { successCount, preferredSuccessCount, + temporarySuccessCount, + longTermSuccessCount, failureCount, results, }; diff --git a/src/data/rebroadcasting-chunk-source.test.ts b/src/data/rebroadcasting-chunk-source.test.ts index 895654035..4a6af1cf2 100644 --- a/src/data/rebroadcasting-chunk-source.test.ts +++ b/src/data/rebroadcasting-chunk-source.test.ts @@ -93,6 +93,8 @@ class MockChunkBroadcaster implements ChunkBroadcaster { public shouldFail = false; public result: BroadcastChunkResult = { successCount: 1, + temporarySuccessCount: 0, + longTermSuccessCount: 1, preferredSuccessCount: 0, failureCount: 0, results: [ @@ -151,6 +153,9 @@ class MockChunkBroadcaster implements ChunkBroadcaster { this.broadcastPromise = null; this.result = { successCount: 1, + preferredSuccessCount: 0, + temporarySuccessCount: 0, + longTermSuccessCount: 1, failureCount: 0, results: [ { @@ -379,6 +384,9 @@ describe('RebroadcastingChunkSource', () => { it('should not cache when success count below threshold', async () => { mockBroadcaster.result = { successCount: 0, + preferredSuccessCount: 0, + temporarySuccessCount: 0, + longTermSuccessCount: 0, failureCount: 1, results: [ { @@ -406,6 +414,9 @@ describe('RebroadcastingChunkSource', () => { // Fix broadcaster result mockBroadcaster.result = { successCount: 1, + preferredSuccessCount: 0, + temporarySuccessCount: 0, + longTermSuccessCount: 1, failureCount: 0, results: [ { @@ -471,6 +482,8 @@ describe('RebroadcastingChunkSource', () => { totalBroadcasts++; return { successCount: 1, + temporarySuccessCount: 0, + longTermSuccessCount: 1, preferredSuccessCount: 0, failureCount: 0, results: [ diff --git a/src/metrics.ts b/src/metrics.ts index 4f28e414a..81a6543fd 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -603,10 +603,18 @@ export const arweaveTxFetchCounter = new promClient.Counter({ labelNames: ['node_type'], }); +/** + * `reason` is set on status="fail" only (empty for successes) and says why the + * post failed: the peer's HTTP status as a string when it answered ("400", + * "429", "503"), or "timeout" / "canceled" / "network" when it did not. Without + * it a peer rejecting chunks is indistinguishable from one rate-limiting us or + * one we cannot reach, and telling them apart otherwise takes the peer + * operator's own logs. + */ export const arweaveChunkPostCounter = new promClient.Counter({ name: 'arweave_chunk_post_total', help: 'Counts individual POST request to endpoint', - labelNames: ['endpoint', 'status', 'role'], + labelNames: ['endpoint', 'status', 'role', 'reason'], }); /** diff --git a/src/types.d.ts b/src/types.d.ts index 623ef1039..be2e43cce 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -936,6 +936,8 @@ type BroadcastChunkResponses = { statusCode: number; canceled: boolean; timedOut: boolean; + /** Peer answered 303: stored in its disk pool, not its long-term home. */ + temporary?: boolean; skipped?: boolean; skipReason?: | 'success_threshold' @@ -947,6 +949,18 @@ interface BroadcastChunkResult { successCount: number; preferredSuccessCount: number; failureCount: number; + /** + * Of `successCount`, how many peers answered 303 ("temporary"): they + * persisted the chunk into their disk pool but are not the long-term home for + * that offset. `longTermSuccessCount` is the 200 remainder. Both outcomes are + * successful propagation — a chunk whose transaction is still pending has no + * absolute offset yet, so 303 is the expected answer even from the tip nodes + * — but the split is the difference between "peers that will keep this" and + * "peers that will drop it when their disk pool matures", which callers + * cannot otherwise see. + */ + temporarySuccessCount: number; + longTermSuccessCount: number; results: BroadcastChunkResponses[]; } From 335480e36de542061347ac3da37f081340b3e5ba Mon Sep 17 00:00:00 2001 From: "Bill Gates (ops agent)" Date: Fri, 18 Sep 2026 17:49:39 +0000 Subject: [PATCH 2/3] fix(chunks): report our own abort deadline as a timeout, and label accepted posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit's finding that chunk-post observability was inaccurate for successful posts and abort-timeout failures. Abort deadline: AbortSignal.timeout() surfaces as axios ERR_CANCELED, which is indistinguishable from a caller cancelling — but CHUNK_POST_ABORT_TIMEOUT_MS (default 2000ms) is normally lower than CHUNK_POST_RESPONSE_TIMEOUT_MS, so this is the COMMON timeout path, and it was being reported as a cancellation. That is not only a mislabelled metric: aggregateStatusCode() maps canceled to 499 (Client Closed Request) and timedOut to 504, so a deadline of ours was reported to the uploader as their client having gone away. The signal is now held so the catch can ask whether it aborted with a TimeoutError reason, which distinguishes our deadline from a real cancellation. Successful posts: `reason` was empty for every success. It now carries the status the peer returned — "200" (stored long-term) or "303" (parked in the disk pool) — and "dry_run" when posting is simulated, so the split is visible in the same counter rather than only via arweave_chunk_post_temporary_total. Tests: our abort deadline reports timedOut (not canceled) and counts as reason="timeout"; an accepted 303 is labelled reason="303". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012NWDKc9pST69qTEha4AGaB --- src/arweave/composite-client.test.ts | 40 ++++++++++++++++++++++++++++ src/arweave/composite-client.ts | 23 +++++++++++++--- src/metrics.ts | 20 +++++++++----- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/arweave/composite-client.test.ts b/src/arweave/composite-client.test.ts index 50a5fbc0f..a08fc0165 100644 --- a/src/arweave/composite-client.test.ts +++ b/src/arweave/composite-client.test.ts @@ -554,6 +554,46 @@ describe('ArweaveCompositeClient', () => { assert.equal(await failReasonCount(baseUrl, '429'), before + 1); }); + // The abort deadline is normally the LOWER of the two, so this is the common + // timeout path. AbortSignal.timeout() surfaces as ERR_CANCELED, which would + // otherwise be reported as a caller cancellation — and aggregateStatusCode() + // turns that into 499 (Client Closed Request), blaming the uploader for our + // own deadline. + it('reports our own abort deadline as a timeout, not a cancellation', async () => { + respond = () => undefined; // never answer + const client: any = createTestClient(); + const before = await failReasonCount(baseUrl, 'timeout'); + const result = await client.postChunkToPeer({ + peer: baseUrl, + chunk: {} as any, + abortTimeout: 50, // fires first + responseTimeout: 5000, + headers: {}, + }); + assert.equal(result.success, false); + assert.equal(result.timedOut, true); + assert.equal(result.canceled, false); + assert.equal(await failReasonCount(baseUrl, 'timeout'), before + 1); + }); + + it('labels accepted posts with the status the peer returned', async () => { + const successReasonCount = async (endpoint: string, reason: string) => { + const { values } = await metrics.arweaveChunkPostCounter.get(); + const s = values.find( + (v: any) => + v.labels.endpoint === endpoint && + v.labels.status === 'success' && + v.labels.reason === reason, + ); + return s?.value ?? 0; + }; + respond = (res) => res.writeHead(303).end(); + const client = createTestClient(); + const before = await successReasonCount(baseUrl, '303'); + await post(client); + assert.equal(await successReasonCount(baseUrl, '303'), before + 1); + }); + it('labels a post the peer never answers as a timeout, not a status code', async () => { // Never respond: the request must hit responseTimeout rather than any // HTTP status, so the reason has to come from the error, not a response. diff --git a/src/arweave/composite-client.ts b/src/arweave/composite-client.ts index 72f06daad..08d45b563 100644 --- a/src/arweave/composite-client.ts +++ b/src/arweave/composite-client.ts @@ -497,6 +497,9 @@ export class ArweaveCompositeClient } private async postChunkToPeer(task: ChunkPostTask): Promise { + // Held so the catch can ask whether the abort was our own deadline firing + // rather than a caller cancelling the request. + const abortSignal = AbortSignal.timeout(task.abortTimeout); try { this.failureSimulator.maybeFail(); @@ -517,6 +520,7 @@ export class ArweaveCompositeClient metrics.arweaveChunkPostCounter.inc({ endpoint: task.peer, status: 'success', + reason: 'dry_run', }); return { @@ -613,6 +617,7 @@ export class ArweaveCompositeClient metrics.arweaveChunkPostCounter.inc({ endpoint: task.peer, status: 'success', + reason: 'dry_run', }); return { @@ -625,7 +630,7 @@ export class ArweaveCompositeClient method: 'POST', url: `${task.peer}/chunk`, data: task.chunk, - signal: AbortSignal.timeout(task.abortTimeout), + signal: abortSignal, timeout: task.responseTimeout, headers: task.headers, // An arweave node returns 200 when it will store the chunk long-term and @@ -645,6 +650,7 @@ export class ArweaveCompositeClient metrics.arweaveChunkPostCounter.inc({ endpoint: task.peer, status: 'success', + reason: String(response.status), }); if (temporary) { metrics.arweaveChunkPostTemporaryCounter.inc({ endpoint: task.peer }); @@ -660,8 +666,19 @@ export class ArweaveCompositeClient let timedOut = false; if (axios.isAxiosError(error)) { - timedOut = error.code === 'ECONNABORTED'; - canceled = error.code === 'ERR_CANCELED'; + // ECONNABORTED is the response timeout. An AbortSignal.timeout() firing + // surfaces as ERR_CANCELED, indistinguishable from a caller cancelling — + // but it is our own deadline, not the caller's, and abortTimeout is + // normally the lower of the two, so this is the common case. Ask the + // signal which it was: AbortSignal.timeout() sets reason to a + // TimeoutError DOMException, while an explicit abort does not. + // Misreporting it matters beyond the metric: aggregateStatusCode() maps + // canceled to 499 (Client Closed Request), blaming the uploader for a + // deadline of ours, where timedOut maps to 504. + const abortedByOurDeadline = + abortSignal.aborted && abortSignal.reason?.name === 'TimeoutError'; + timedOut = error.code === 'ECONNABORTED' || abortedByOurDeadline; + canceled = error.code === 'ERR_CANCELED' && !abortedByOurDeadline; } // A peer that answered tells us why it refused the chunk (400 unknown diff --git a/src/metrics.ts b/src/metrics.ts index 81a6543fd..56a392e48 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -604,12 +604,20 @@ export const arweaveTxFetchCounter = new promClient.Counter({ }); /** - * `reason` is set on status="fail" only (empty for successes) and says why the - * post failed: the peer's HTTP status as a string when it answered ("400", - * "429", "503"), or "timeout" / "canceled" / "network" when it did not. Without - * it a peer rejecting chunks is indistinguishable from one rate-limiting us or - * one we cannot reach, and telling them apart otherwise takes the peer - * operator's own logs. + * `reason` carries the outcome behind `status`: + * + * - success: the accepted HTTP status, "200" (peer will store it long-term) or + * "303" (peer parked it in its disk pool), or "dry_run" when posting is + * simulated. The 303 subset is also counted by + * `arweave_chunk_post_temporary_total`. + * - fail: the peer's HTTP status as a string when it answered ("400", "429", + * "503"); otherwise "timeout" (our response or abort deadline), "canceled" + * (the caller aborted), "network" (unreachable), or "invalid_chunk" / + * "invalid_proof" for dry-run validation failures. + * + * Without it, a peer rejecting chunks is indistinguishable from one + * rate-limiting us or one we cannot reach, and telling them apart otherwise + * takes the peer operator's own logs. */ export const arweaveChunkPostCounter = new promClient.Counter({ name: 'arweave_chunk_post_total', From 2cfae4b0c194b38e62614dc8a836e13347fffcfe Mon Sep 17 00:00:00 2001 From: "Bill Gates (ops agent)" Date: Fri, 18 Sep 2026 18:45:42 +0000 Subject: [PATCH 3/3] refactor(chunks): create the abort signal lazily and name non-HTTP failures Three points from a self-review pass: - AbortSignal.timeout() was created at function entry, arming a timer on every call including the dry-run paths, which return before any request is made. It is now created immediately before the POST. - A throw that is not an axios error (e.g. the failure simulator) was labelled reason="network", which would send an operator looking at the wrong thing. Those now report reason="error". - BroadcastChunkResult doc no longer says the new counts are "of successCount": they are derived from `results`, which is authoritative, while successCount is deliberately racy, so the sum can differ slightly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012NWDKc9pST69qTEha4AGaB --- src/arweave/composite-client.ts | 23 +++++++++++++++++------ src/types.d.ts | 6 +++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/arweave/composite-client.ts b/src/arweave/composite-client.ts index 08d45b563..e0b9b7508 100644 --- a/src/arweave/composite-client.ts +++ b/src/arweave/composite-client.ts @@ -497,9 +497,11 @@ export class ArweaveCompositeClient } private async postChunkToPeer(task: ChunkPostTask): Promise { - // Held so the catch can ask whether the abort was our own deadline firing - // rather than a caller cancelling the request. - const abortSignal = AbortSignal.timeout(task.abortTimeout); + // Assigned just before the request so the catch can ask whether the abort + // was our own deadline firing rather than a caller cancelling. Created + // lazily: the dry-run paths below return without posting, and creating the + // signal up front would arm a timer per call for nothing. + let abortSignal: AbortSignal | undefined; try { this.failureSimulator.maybeFail(); @@ -626,6 +628,8 @@ export class ArweaveCompositeClient }; } + abortSignal = AbortSignal.timeout(task.abortTimeout); + const response = await axios({ method: 'POST', url: `${task.peer}/chunk`, @@ -665,7 +669,8 @@ export class ArweaveCompositeClient let canceled = false; let timedOut = false; - if (axios.isAxiosError(error)) { + const isAxiosError = axios.isAxiosError(error); + if (isAxiosError) { // ECONNABORTED is the response timeout. An AbortSignal.timeout() firing // surfaces as ERR_CANCELED, indistinguishable from a caller cancelling — // but it is our own deadline, not the caller's, and abortTimeout is @@ -676,7 +681,8 @@ export class ArweaveCompositeClient // canceled to 499 (Client Closed Request), blaming the uploader for a // deadline of ours, where timedOut maps to 504. const abortedByOurDeadline = - abortSignal.aborted && abortSignal.reason?.name === 'TimeoutError'; + abortSignal?.aborted === true && + abortSignal.reason?.name === 'TimeoutError'; timedOut = error.code === 'ECONNABORTED' || abortedByOurDeadline; canceled = error.code === 'ERR_CANCELED' && !abortedByOurDeadline; } @@ -693,7 +699,12 @@ export class ArweaveCompositeClient ? 'timeout' : canceled ? 'canceled' - : 'network'; + : isAxiosError + ? 'network' + : // Not an HTTP failure at all: a throw from our own code path + // (e.g. the failure simulator). Calling it "network" would + // send an operator looking at the wrong thing. + 'error'; metrics.arweaveChunkPostCounter.inc({ endpoint: task.peer, diff --git a/src/types.d.ts b/src/types.d.ts index be2e43cce..3b1cff88b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -950,7 +950,7 @@ interface BroadcastChunkResult { preferredSuccessCount: number; failureCount: number; /** - * Of `successCount`, how many peers answered 303 ("temporary"): they + * How many accepting peers answered 303 ("temporary"): they * persisted the chunk into their disk pool but are not the long-term home for * that offset. `longTermSuccessCount` is the 200 remainder. Both outcomes are * successful propagation — a chunk whose transaction is still pending has no @@ -958,6 +958,10 @@ interface BroadcastChunkResult { * — but the split is the difference between "peers that will keep this" and * "peers that will drop it when their disk pool matures", which callers * cannot otherwise see. + * + * Both are derived from `results`, which is authoritative, rather than from + * the early-termination counters above — so their sum can differ slightly + * from `successCount`, which is deliberately racy. */ temporarySuccessCount: number; longTermSuccessCount: number;