Skip to content

Commit 25fa2cc

Browse files
authored
feat: merge-train/spartan (#22980)
BEGIN_COMMIT_OVERRIDE fix(test): warp L1 forward when proposer scan hits EpochNotStable (#22967) test(e2e): fail epochs tests on proposer-rollup-check-failed (#22965) fix: grafana switch to aztec_status="proposed" (#22978) chore: update benchmark scraper (#22984) test(e2e): migrate simple epoch tests to pipelining (#22973) chore: remove top-level yarn.lock (#22987) refactor(archiver)!: unify L2BlockSource checkpoint lookups via query objects (#22933) fix(sequencer): bounded sweep instead of event scan for governance proposal check (#22989) fix(docs): allow webapp-tutorial yarn install to populate empty lockfile in CI (#23000) test(e2e): enable pipelining in l1-reorgs and mbps redistribution tests (#23009) fix(archiver): restore pending block height metric under pipelining (#22994) chore(p2p): remove skipped validation result option (#23034) refactor(p2p)!: remove slow tx collection flow (#22878) chore(spartan): add next-net-clone environment config (#22995) chore(sequencer): add context to proposer-rollup-check-failed logs (#23071) test(e2e): wait for archiver sync before asserting pipelining (#22997) refactor(node-rpc)!: remove deprecated AztecNode methods and L2BlockSource tip helpers (#22934) feat(p2p): detect and track announce IP changes at runtime (#22405) test: mark tx_stats_bench 10 TPS as flake-retryable on merge-train/spartan (#23083) fix(sequencer): bind vote-only multicalls to target slot under pipelining (#23090) feat(sequencer): build optimistically across pruning epoch boundary (#23056) fix(sequencer): use chainTipsOverride.pending for log context (#23098) test(e2e): relax post-boundary slot assertion in epochs_proof_at_boundary (#23108) fix(bb-prover): pool long-lived bb verifier processes instead of spawning per-call (#23093) fix(sequencer): anchor fee asset price modifier to predicted parent (#23113) chore: error log when L1 head timestamp drifts (#22947) fix(sequencer): override full parent checkpoint cell in pipelined simulation (#23073) test(e2e): enable pipelining on missed l1 slot test (#23068) fix: more robust metrics reporting in IRM monitor (#23038) fix: preserve LMDB slashing protection (#23145) test(e2e): enable pipelining on p2p tests (#23070) fix(archiver): move L2 tips cache refresh out of write transactions (#23110) test(e2e): fix data_withholding_slash flake by freezing L1 across restart (#23162) fix(validator): include proposed checkpoint out-hashes when validating checkpoint proposals (#23119) refactor(config): drop nested config option, flatten l1Contracts (#23143) test(e2e): bump bash TIMEOUT for e2e_p2p/add_rollup to match jest 20m (#23177) fix(p2p): chunk archive of mined txs on block finalization (A-969) (#23085) fix(p2p): stream tx pool hydration to bound startup memory (A-968) (#23086) chore: remove orphan --archiver flag usages from start invocations (#23186) feat(ci): daily merge-train/spartan stale-PR notifier (#23189) fix: preserve contract artifact permissions (#23174) fix(ci3): accept slashes in /list/<path:key> for merge-train history (#23160) feat(ci): route merge-train/spartan flake notifications to #team-alpha-ci (#23219) fix(cheat-codes): wait for post-warp L2 block in warpL2TimeAtLeastTo (#23213) feat: slash attesters signing over bad checkpoints (#23180) refactor(prover-client): split orchestrator into sub-tree + top-tree pair (#22996) fix(srs): retry transient CRS HTTP downloads with exponential backoff (#23244) refactor(p2p): remove old reqresp mode (#23158) docs(sequencer-client): rewrite top-level and timing READMEs (#23149) fix(aztec-node): include upcoming checkpoint's L1 to L2 messages in simulatePublicCalls (#23163) END_COMMIT_OVERRIDE
2 parents 34c362b + ae3bfc4 commit 25fa2cc

334 files changed

Lines changed: 10176 additions & 4308 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy-irm.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ jobs:
6969
CLUSTER_NAME: ${{ inputs.cluster }}
7070
GKE_CLUSTER_CONTEXT: "gke_testnet-440309_us-west1-a_${{ inputs.cluster }}"
7171
REGION: us-west1-a
72-
INFURA_SECRET_NAME: infura-${{ inputs.l1_network }}-url
72+
ETHEREUM_HOSTS_SECRET_NAME: irm-ethereum-hosts-${{ inputs.l1_network }}
7373

7474
runs-on: ubuntu-latest
7575
steps:
@@ -150,4 +150,4 @@ jobs:
150150
echo "L1 network: ${{ inputs.l1_network }}"
151151
echo "Image tag: ${IMAGE_TAG}"
152152
153-
./spartan/metrics/irm-monitor/scripts/update-monitoring.sh $NAMESPACE $MONITORING_NAMESPACE ${{ inputs.network }} $INFURA_SECRET_NAME
153+
./spartan/metrics/irm-monitor/scripts/update-monitoring.sh $NAMESPACE $MONITORING_NAMESPACE ${{ inputs.network }} $ETHEREUM_HOSTS_SECRET_NAME

.github/workflows/deploy-network.yml

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ on:
1010
required: true
1111
type: string
1212
semver:
13-
description: "Semver version (e.g., 2.3.4)"
14-
required: true
13+
description: "Semver version (e.g., 2.3.4). Used to construct docker image if aztec_docker_image is not set."
14+
required: false
1515
type: string
16-
docker_image_tag:
17-
description: "Full docker image tag (optional, defaults to semver)"
16+
aztec_docker_image:
17+
description: "Full Aztec docker image (e.g., aztecprotocol/aztec:2.3.4). If not set, constructed from semver."
1818
required: false
1919
type: string
2020
ref:
@@ -50,11 +50,11 @@ on:
5050
- testnet
5151
- mainnet
5252
semver:
53-
description: "Semver version (e.g., 2.3.4)"
54-
required: true
53+
description: "Semver version (e.g., 2.3.4). Used to construct docker image if aztec_docker_image is not set."
54+
required: false
5555
type: string
56-
docker_image_tag:
57-
description: "Full docker image tag (optional, defaults to semver)"
56+
aztec_docker_image:
57+
description: "Full Aztec docker image (e.g., aztecprotocol/aztec:2.3.4). If not set, constructed from semver."
5858
required: false
5959
type: string
6060
namespace:
@@ -76,7 +76,7 @@ on:
7676
type: string
7777

7878
concurrency:
79-
group: deploy-network-${{ inputs.network }}-${{ inputs.namespace || inputs.network }}-${{ inputs.semver }}-${{ github.ref || github.ref_name }}
79+
group: deploy-network-${{ inputs.network }}-${{ inputs.namespace || inputs.network }}-${{ inputs.aztec_docker_image || inputs.semver }}-${{ github.ref || github.ref_name }}
8080
cancel-in-progress: true
8181

8282
jobs:
@@ -120,16 +120,33 @@ jobs:
120120
exit 1
121121
fi
122122
123-
# Validate semver format
124-
if ! echo "${{ inputs.semver }}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$'; then
125-
echo "Error: Invalid semver format '${{ inputs.semver }}'. Expected format: X.Y.Z or X.Y.Z-suffix"
123+
# Require at least one of aztec_docker_image or semver
124+
if [[ -z "${{ inputs.aztec_docker_image }}" && -z "${{ inputs.semver }}" ]]; then
125+
echo "Error: Either 'aztec_docker_image' or 'semver' must be provided"
126126
exit 1
127127
fi
128128
129-
# Extract major version for v2 check
130-
major_version="${{ inputs.semver }}"
131-
major_version="${major_version%%.*}"
132-
echo "MAJOR_VERSION=$major_version" >> $GITHUB_ENV
129+
# Validate semver format if provided
130+
if [[ -n "${{ inputs.semver }}" ]]; then
131+
if ! echo "${{ inputs.semver }}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$'; then
132+
echo "Error: Invalid semver format '${{ inputs.semver }}'. Expected format: X.Y.Z or X.Y.Z-suffix"
133+
exit 1
134+
fi
135+
fi
136+
137+
# Resolve the docker image
138+
if [[ -n "${{ inputs.aztec_docker_image }}" ]]; then
139+
AZTEC_DOCKER_IMAGE="${{ inputs.aztec_docker_image }}"
140+
else
141+
AZTEC_DOCKER_IMAGE="aztecprotocol/aztec:${{ inputs.semver }}"
142+
fi
143+
echo "AZTEC_DOCKER_IMAGE=$AZTEC_DOCKER_IMAGE" >> $GITHUB_ENV
144+
145+
# Only use the separate prover-agent image for official semver builds;
146+
# for custom images, let the deploy script fall back to AZTEC_DOCKER_IMAGE
147+
if [[ -n "${{ inputs.semver }}" ]]; then
148+
echo "PROVER_AGENT_DOCKER_IMAGE=aztecprotocol/aztec-prover-agent:${{ inputs.semver }}" >> $GITHUB_ENV
149+
fi
133150
134151
- name: Store the GCP key in a file
135152
env:
@@ -174,12 +191,12 @@ jobs:
174191
RUN_ID: ${{ github.run_id }}
175192
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
176193
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
177-
REF_NAME: "v${{ inputs.semver }}"
194+
REF_NAME: ${{ inputs.semver && format('v{0}', inputs.semver) || '' }}
178195
GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
179196
NAMESPACE: ${{ inputs.namespace }}
180-
AZTEC_DOCKER_IMAGE: "aztecprotocol/aztec:${{ inputs.docker_image_tag || inputs.semver }}"
197+
AZTEC_DOCKER_IMAGE: ${{ env.AZTEC_DOCKER_IMAGE }}
181198
CREATE_ROLLUP_CONTRACTS: ${{ inputs.deploy_contracts == true && 'true' || '' }}
182-
PROVER_AGENT_DOCKER_IMAGE: "aztecprotocol/aztec-prover-agent:${{ inputs.docker_image_tag || inputs.semver }}"
199+
PROVER_AGENT_DOCKER_IMAGE: ${{ env.PROVER_AGENT_DOCKER_IMAGE || env.AZTEC_DOCKER_IMAGE }}
183200
VALIDATOR_HA_DOCKER_IMAGE: ${{ inputs.ha_docker_image || '' }}
184201
run: |
185202
echo "Deploying network: ${{ inputs.network }}"
@@ -209,7 +226,7 @@ jobs:
209226
echo "| Item | Value |"
210227
echo "|------|-------|"
211228
echo "| Network | \`${{ inputs.network }}\` |"
212-
echo "| Semver | \`${{ inputs.semver }}\` |"
229+
echo "| Docker Image | \`${{ env.AZTEC_DOCKER_IMAGE }}\` |"
213230
echo "| Ref | \`${{ steps.checkout-ref.outputs.ref }}\` |"
214231
if [[ -n "${{ inputs.source_tag }}" ]]; then
215232
echo "| Source Tag | [\`${{ inputs.source_tag }}\`](https://github.com/${{ github.repository }}/releases/tag/${{ inputs.source_tag }}) |"
@@ -229,7 +246,7 @@ jobs:
229246
230247
CHANNEL="#alerts-${{ inputs.network }}"
231248
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
232-
TEXT="Deploy Network workflow FAILED for *${{ inputs.network }}* (version ${{ inputs.semver }}): <${RUN_URL}|View Run> (🤖)"
249+
TEXT="Deploy Network workflow FAILED for *${{ inputs.network }}* (image ${{ env.AZTEC_DOCKER_IMAGE }}): <${RUN_URL}|View Run> (🤖)"
233250
234251
# Post to Slack and capture timestamp for permalink
235252
RESP=$(curl -sS -X POST https://slack.com/api/chat.postMessage \
@@ -247,11 +264,11 @@ jobs:
247264
fi
248265
249266
# Dispatch ClaudeBox to investigate the failure
250-
PROMPT="Deployment of ${{ inputs.network }} (version ${{ inputs.semver }}) failed. \
267+
PROMPT="Deployment of ${{ inputs.network }} (image ${{ env.AZTEC_DOCKER_IMAGE }}) failed. \
251268
Follow .claude/claudebox/deploy-investigation.md to investigate. \
252269
GitHub Actions run: ${RUN_URL}. \
253-
Network: ${{ inputs.network }}. Version: ${{ inputs.semver }}. \
254-
Docker image: ${{ inputs.docker_image_tag || inputs.semver }}. \
270+
Network: ${{ inputs.network }}. \
271+
Docker image: ${{ env.AZTEC_DOCKER_IMAGE }}. \
255272
Git ref: ${{ steps.checkout-ref.outputs.ref }}. \
256273
Namespace: ${{ inputs.namespace || inputs.network }}. \
257274
Deploy contracts: ${{ inputs.deploy_contracts }}."

.github/workflows/deploy-next-net.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010
workflow_dispatch:
1111
inputs:
1212
image_tag:
13-
description: 'Docker image tag (e.g., 2.3.4, 3.0.0-nightly.20251004-amd64, or leave empty for latest nightly)'
13+
description: "Docker image tag (e.g., 2.3.4, 3.0.0-nightly.20251004-amd64, or leave empty for latest nightly)"
1414
required: false
1515
type: string
1616

@@ -67,6 +67,6 @@ jobs:
6767
with:
6868
network: next-net
6969
semver: ${{ needs.get-image-tag.outputs.semver }}
70-
docker_image_tag: ${{ needs.get-image-tag.outputs.tag }}
70+
aztec_docker_image: "aztecprotocol/aztec:${{ needs.get-image-tag.outputs.tag }}"
7171
ref: ${{ github.ref }}
7272
secrets: inherit
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Merge-Train Stale Check
2+
3+
on:
4+
schedule:
5+
# Daily at 09:15 UTC — once per day, off the round-minute mark.
6+
- cron: "15 9 * * *"
7+
workflow_dispatch:
8+
9+
jobs:
10+
spartan:
11+
name: Check merge-train/spartan
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
pull-requests: read
16+
steps:
17+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
18+
- name: Run stale check
19+
env:
20+
GH_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }}
21+
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
22+
run: ./ci3/merge_train_stale_check merge-train/spartan '#team-alpha'

.test_patterns.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,14 @@ tests:
294294
owners:
295295
- *palla
296296

297+
# tx_stats_bench's 10 TPS sub-test occasionally returns valid:false from a single IVC
298+
# verification under heavy concurrency (8 parallel verifiers, each spawning a fresh bb subprocess
299+
# via the bb.js NativeUnixSocket backend introduced in #21564). The serial sub-tests pass.
300+
- regex: "tx_stats_bench"
301+
error_regex: "tx_stats_bench\\.test\\.ts:[0-9]+:[0-9]+"
302+
owners:
303+
- *charlie
304+
297305
- regex: "src/e2e_token_bridge_tutorial.test.ts"
298306
error_regex: "Error: Unable to find low leaf for block"
299307
owners:

barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,33 @@ ChonkVerify::Response ChonkVerify::execute(const BBApiRequest& /*request*/) &&
189189
return { .valid = verified };
190190
}
191191

192+
ChonkVerifyFromFields::Response ChonkVerifyFromFields::execute(const BBApiRequest& /*request*/) &&
193+
{
194+
BB_BENCH_NAME(MSGPACK_SCHEMA_NAME);
195+
196+
using VerificationKey = Chonk::MegaVerificationKey;
197+
validate_vk_size<VerificationKey>(vk);
198+
199+
auto hiding_kernel_vk = std::make_shared<VerificationKey>(from_buffer<VerificationKey>(vk));
200+
201+
// Validate total field count: must match num_public_inputs + fixed overhead.
202+
const size_t expected_field_count =
203+
static_cast<size_t>(hiding_kernel_vk->num_public_inputs) + ChonkProof::PROOF_LENGTH_WITHOUT_PUB_INPUTS;
204+
if (proof.size() != expected_field_count) {
205+
throw_or_abort("ChonkVerifyFromFields: proof has wrong field count: expected " +
206+
std::to_string(expected_field_count) + ", got " + std::to_string(proof.size()));
207+
}
208+
209+
// Split the flat field array into the structured ChonkProof. Layout knowledge stays here.
210+
auto structured = ChonkProof::from_field_elements(proof);
211+
212+
auto vk_and_hash = std::make_shared<ChonkNativeVerifier::VKAndHash>(hiding_kernel_vk);
213+
ChonkNativeVerifier verifier(vk_and_hash);
214+
const bool verified = verifier.verify(structured);
215+
216+
return { .valid = verified };
217+
}
218+
192219
ChonkBatchVerify::Response ChonkBatchVerify::execute(const BBApiRequest& /*request*/) &&
193220
{
194221
BB_BENCH_NAME(MSGPACK_SCHEMA_NAME);

barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.hpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,36 @@ struct ChonkVerify {
157157
bool operator==(const ChonkVerify&) const = default;
158158
};
159159

160+
/**
161+
* @struct ChonkVerifyFromFields
162+
* @brief Verify a Chonk proof passed as a flat field-element array (with public inputs prepended).
163+
*
164+
* The split into structured ChonkProof sub-proofs is done server-side via
165+
* ChonkProof::from_field_elements, so callers do not need to know the per-component sub-proof
166+
* sizes. This is the recommended entry point for TypeScript callers that hold the proof as a
167+
* flat Fr[] (e.g. from tx.chonkProof.attachPublicInputs).
168+
*/
169+
struct ChonkVerifyFromFields {
170+
static constexpr const char MSGPACK_SCHEMA_NAME[] = "ChonkVerifyFromFields";
171+
172+
struct Response {
173+
static constexpr const char MSGPACK_SCHEMA_NAME[] = "ChonkVerifyFromFieldsResponse";
174+
175+
/** @brief True if the proof is valid */
176+
bool valid;
177+
SERIALIZATION_FIELDS(valid);
178+
bool operator==(const Response&) const = default;
179+
};
180+
181+
/** @brief Flat proof field elements with public inputs prepended */
182+
std::vector<bb::fr> proof;
183+
/** @brief The verification key */
184+
std::vector<uint8_t> vk;
185+
Response execute(const BBApiRequest& request = {}) &&;
186+
SERIALIZATION_FIELDS(proof, vk);
187+
bool operator==(const ChonkVerifyFromFields&) const = default;
188+
};
189+
160190
/**
161191
* @struct ChonkComputeVk
162192
* @brief Compute MegaHonk verification key for a circuit to be accumulated in Chonk

barretenberg/cpp/src/barretenberg/bbapi/bbapi_execute.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ using Command = NamedUnion<AvmProve,
2727
ChonkAccumulate,
2828
ChonkProve,
2929
ChonkVerify,
30+
ChonkVerifyFromFields,
3031
ChonkBatchVerify,
3132
VkAsFields,
3233
MegaVkAsFields,
@@ -90,6 +91,7 @@ using CommandResponse = NamedUnion<ErrorResponse,
9091
ChonkAccumulate::Response,
9192
ChonkProve::Response,
9293
ChonkVerify::Response,
94+
ChonkVerifyFromFields::Response,
9395
ChonkBatchVerify::Response,
9496
VkAsFields::Response,
9597
MegaVkAsFields::Response,

barretenberg/cpp/src/barretenberg/srs/factories/http_download.hpp

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#pragma once
2+
#include "barretenberg/common/log.hpp"
23
#include "barretenberg/common/throw_or_abort.hpp"
34
#include <cstdint>
45

@@ -24,13 +25,19 @@
2425
#pragma clang diagnostic pop
2526
#endif
2627

28+
#include <chrono>
2729
#include <string>
30+
#include <thread>
2831
#include <vector>
2932

3033
namespace bb::srs {
3134

3235
/**
3336
* @brief Download data from a URL with optional Range header support
37+
*
38+
* Retries on transient failures (connection errors, 5xx, 429) with exponential
39+
* backoff so a momentary CDN/network blip doesn't propagate as a hard failure.
40+
*
3441
* @param url Full URL (e.g., "http://crs.aztec-cdn.foundation/g1.dat")
3542
* @param start_byte Starting byte for range request (0 for no range)
3643
* @param end_byte Ending byte for range request (0 for no range)
@@ -70,20 +77,48 @@ inline std::vector<uint8_t> http_download([[maybe_unused]] const std::string& ur
7077
headers.emplace("Range", "bytes=" + std::to_string(start_byte) + "-" + std::to_string(end_byte));
7178
}
7279

73-
// Download
74-
auto res = cli.Get(path.c_str(), headers);
80+
constexpr int max_attempts = 3;
81+
// Bound retry-induced latency: each retry attempt uses tighter timeouts than the first try
82+
// so the worst-case extra time (backoffs + retry attempts) stays under ~15s.
83+
// Math: backoff 1s + 2s + 2 retries * 5s timeout = 13s.
84+
constexpr int retry_timeout_seconds = 5;
7585

76-
if (!res) {
77-
throw_or_abort("HTTP request failed for " + url + ": " + httplib::to_string(res.error()));
78-
}
86+
std::chrono::milliseconds backoff{ 1000 };
87+
std::string last_error;
88+
for (int attempt = 1; attempt <= max_attempts; ++attempt) {
89+
if (attempt == 2) {
90+
cli.set_connection_timeout(retry_timeout_seconds);
91+
cli.set_read_timeout(retry_timeout_seconds);
92+
}
7993

80-
if (res->status != 200 && res->status != 206) {
81-
throw_or_abort("HTTP request failed for " + url + " with status " + std::to_string(res->status));
82-
}
94+
auto res = cli.Get(path.c_str(), headers);
95+
96+
if (res && (res->status == 200 || res->status == 206)) {
97+
const std::string& body = res->body;
98+
return std::vector<uint8_t>(body.begin(), body.end());
99+
}
83100

84-
// Convert string body to vector<uint8_t>
85-
const std::string& body = res->body;
86-
return std::vector<uint8_t>(body.begin(), body.end());
101+
bool retryable = false;
102+
if (!res) {
103+
last_error = httplib::to_string(res.error());
104+
retryable = true;
105+
} else {
106+
last_error = "status " + std::to_string(res->status);
107+
// Retry on 5xx and 429 (rate limit); other 4xx are client errors and won't change with retry.
108+
retryable = res->status >= 500 || res->status == 429;
109+
}
110+
111+
if (!retryable || attempt == max_attempts) {
112+
throw_or_abort("HTTP request failed for " + url + ": " + last_error + " (after " + std::to_string(attempt) +
113+
" attempt" + (attempt == 1 ? "" : "s") + ")");
114+
}
115+
116+
vinfo("HTTP download of ", url, " failed (", last_error, "), retrying in ", backoff.count(), "ms");
117+
std::this_thread::sleep_for(backoff);
118+
backoff *= 2;
119+
}
120+
// Unreachable: loop above either returns on success or throws on the final attempt.
121+
throw_or_abort("HTTP request failed for " + url + ": " + last_error);
87122
#endif
88123
}
89124
} // namespace bb::srs

0 commit comments

Comments
 (0)