← Basics
Publishedrole: extensionlicense: BSD-3-Clause

tom_basics_network

tom_basics_network · v1.0.1

Network extension of `tom_basics`: HTTP retry with configurable exponential backoff and network-based server discovery for distributed Tom applications.

View repository → See License
Status
Published
LOC
299
Tests
12
Test LOC
100

Overview

From the module readme.md file:

What it enables

Enables Resilient HTTP calls, Configurable retry backoff, Server discovery.

Relationships

Standalone — no declared relationships.

tom_basics_network

> Part of the Tom framework by al-the-bear. > © 2024–2026 Peter Nicolai Alexis Kyaw — BSD-3-Clause, see LICENSE.

Network utilities for Tom applications — HTTP retry with exponential backoff and LAN server discovery.

tom_basics_network packages the two networking concerns that almost every distributed Tom application needs but that the standard http package leaves to the caller: surviving transient failures and finding a peer on the local network without a configured address. It is a small, focused, pure-Dart package — two independent subsystems, no shared state, no platform plugins — built directly on package:http and dart:io.

The two subsystems are deliberately decoupled. You can take withRetry to wrap any Future-returning operation and never touch discovery, or use ServerDiscovery to locate a server and hand the URL to your own client. They share a package only because they share an audience: code that talks to other machines over an unreliable link.

---

Overview

A networked operation fails for two broad reasons. Either the transport hiccuped — a dropped socket, a timeout, a server that returned 503 because it was briefly overloaded — or something is actually wrong — a 404, a malformed request, an authentication failure. The first kind is worth retrying; the second is not. Retrying a 400 just wastes time and hammers the server.

tom_basics_network encodes that distinction:

  • HTTP retry (withRetry, RetryConfig, RetryableResponse) — wraps an

operation in a backoff loop that retries only transient transport errors and only retryable HTTP status codes (5xx, 408, 429), and surfaces a single RetryExhaustedException when the budget runs out. - Server discovery (ServerDiscovery, DiscoveryOptions, DiscoveredServer) — probes localhost, this machine's own LAN addresses, and (optionally) the whole /24 subnet for a Tom server answering a known status endpoint, returning the first match, all matches, or throwing if none respond.

Neither subsystem holds state between calls. withRetry is a top-level function; ServerDiscovery exposes only static methods. Configuration is passed in per call as immutable value objects (RetryConfig, DiscoveryOptions), so the same code is safe to call concurrently from multiple isolates.

---

Installation

dependencies:
  tom_basics_network: ^1.0.1

Or from the command line:

dart pub add tom_basics_network

Then import the single entry point:

import 'package:tom_basics_network/tom_basics_network.dart';

Requires Dart SDK ^3.10.4. The package depends only on package:http; everything else comes from the Dart core libraries (dart:io, dart:async). It runs on any platform with dart:io (desktop, server, CLI) — server discovery uses dart:io networking and is not available on the web.

---

Features

HTTP retry

APIKindPurpose
withRetry<T> function Run an operation, retrying transient failures with backoff
RetryConfig class Per-call retry policy: delay schedule + retry callback
RetryConfig.defaultConfig const The default policy (5 retries: 2/4/8/16/32 s)
kDefaultRetryDelaysMs const list The default backoff schedule in milliseconds
RetryExhaustedException exception Thrown when every retry has failed
RetryableResponse extension http.Response.isRetryable for status-code checks

Server discovery

APIKindPurpose
ServerDiscovery.discover static First server found, or null
ServerDiscovery.discoverOrThrow static First server found, or throw
ServerDiscovery.discoverAllstaticEvery reachable server
ServerDiscovery.getLocalIpAddresses static This machine's non-loopback IPv4 addresses
ServerDiscovery.getSubnetAddresses static The 253 other hosts in a /24
DiscoveryOptions class Port, timeout, concurrency, subnet toggle, validator
DiscoveredServer class A found server: URL + parsed status payload
DiscoveryFailedException exception Thrown by discoverOrThrow when nothing answers

---

Quick start

Retry a flaky request

import 'package:http/http.dart' as http;
import 'package:tom_basics_network/tom_basics_network.dart';

Future<String> fetchScore() => withRetry(() async {
      final response = await http.get(Uri.parse('https://example.com/score'));
      if (response.isRetryable) {
        // A 5xx/408/429 — throw so withRetry backs off and tries again.
        throw http.ClientException('transient ${response.statusCode}');
      }
      return response.body;
    });

With no RetryConfig, withRetry uses RetryConfig.defaultConfig: up to five retries spaced 2 s, 4 s, 8 s, 16 s, 32 s apart. A ClientException is one of the error types it treats as transient, so the call above retries on those status codes and rethrows anything else immediately.

See the backoff schedule

import 'package:tom_basics_network/tom_basics_network.dart';

void main() {
  final schedule = kDefaultRetryDelaysMs.map((ms) => '${ms / 1000}s').join(', ');
  print('Default retry schedule: $schedule');
}

Output:

Default retry schedule: 2.0s, 4.0s, 8.0s, 16.0s, 32.0s

Find a Tom server on the LAN

import 'package:tom_basics_network/tom_basics_network.dart';

Future<void> main() async {
  final server = await ServerDiscovery.discover();
  if (server == null) {
    print('No server responded.');
    return;
  }
  print('Found ${server.service} v${server.version} at ${server.serverUrl}');
}

discover() returns the first server that answers GET /status with 200 and a JSON object, scanning localhost first, then this machine's LAN addresses, then the rest of the /24. It returns null rather than throwing when nothing answers — see discoverOrThrow for the throwing variant.

---

Example projects

ExampleWhat it shows
example/tom_basics_network_example.dart The default and a custom retry schedule, printed
tom_basics_network_sample The full retry-with-backoff and server-discovery surface, end to end. (article-grade sample, seven runnable examples, runs offline)

Run the local example with:

dart run example/tom_basics_network_example.dart

For the full worked tour — retry, exhaustion, the retryable set, the default backoff schedule, server discovery and subnet arithmetic — see the tom_basics_network_sample; the usage sections below are the inline reference.

---

Usage

HTTP retry

What counts as retryable

withRetry retries an operation when it throws one of these transport errors:

  • SocketException — connection refused / reset / no route
  • HttpException — a dart:io HTTP-layer failure
  • TimeoutException — the operation took too long
  • http.ClientException — a package:http transport failure
  • OSError — a lower-level OS networking error

Anything else — an ArgumentError, a FormatException, a thrown 404 handler — is not retried and propagates immediately. The point is to retry the network, not your bugs.

For HTTP status codes, the RetryableResponse extension classifies a response without you memorising the numbers:

import 'package:http/http.dart' as http;
import 'package:tom_basics_network/tom_basics_network.dart';

bool worthRetrying(http.Response r) => r.isRetryable;
// true for 500–599, 408 (Request Timeout), 429 (Too Many Requests)
// false for 200, 404, 400, 401, ...

Because withRetry reacts to thrown errors, the idiom is to inspect the response and throw a transient error when isRetryable is true (as in the quick-start example), letting non-retryable responses return normally.

Configuring the backoff

RetryConfig carries two things: the delay schedule and an optional callback.

import 'package:tom_basics_network/tom_basics_network.dart';

const fastConfig = RetryConfig(
  // Three retries: 100 ms, 200 ms, 400 ms.
  retryDelaysMs: [100, 200, 400],
  onRetry: _logRetry,
);

void _logRetry(int attempt, Object error, Duration nextDelay) {
  print('attempt $attempt failed ($error); retrying in '
      '${nextDelay.inMilliseconds}ms');
}

The length of retryDelaysMs is the retry budget: a list of three delays means the operation runs at most four times (one initial attempt plus three retries). onRetry fires once before each backoff sleep, receiving the 1-based attempt number, the error that triggered the retry, and the delay about to be waited — useful for logging or metrics without changing the control flow.

Narrowing what gets retried

Pass a shouldRetry predicate to override the default transport-error set. When supplied, an error is retried only if both shouldRetry(error) returns true and the error is in the built-in retryable set:

import 'dart:io';
import 'package:tom_basics_network/tom_basics_network.dart';

final result = await withRetry(
  _doRequest,
  shouldRetry: (error) => error is SocketException, // timeouts won't retry
);

This lets you be stricter than the default (retry connection failures but not timeouts, say). It cannot make a non-transport error retryable — that set is the floor.

When retries run out

After the last delay, withRetry gives up by throwing RetryExhaustedException, which packages the final failure for inspection:

import 'package:tom_basics_network/tom_basics_network.dart';

try {
  await withRetry(_doRequest, config: const RetryConfig(retryDelaysMs: [50, 50]));
} on RetryExhaustedException catch (e) {
  print('Gave up after ${e.attempts} attempts; last error: ${e.lastError}');
  // e.lastStackTrace is available for logging the original failure site.
}
FieldTypeMeaning
lastError Object The error from the final failed attempt
lastStackTrace StackTrace? Where that error was thrown
attempts int Total attempts made (initial + retries)

Server discovery

How a scan proceeds

ServerDiscovery looks for a Tom server by probing candidate hosts in order of likelihood:

ServerDiscovery.discover(options)
        │
        ├─ 1. localhost            (the same machine)
        ├─ 2. local IPv4 addresses (this host's LAN interfaces)
        └─ 3. /24 subnet           (the other 253 hosts) — only if scanSubnet
                 │
                 └─ for each candidate: GET http://<host>:<port><statusPath>
                        expect 200 + JSON object → DiscoveredServer

Each probe is a single GET to http://<host>:<port><statusPath> with a short timeout. A host qualifies when it answers 200 with a JSON object body; that object becomes the DiscoveredServer.status map. Subnet scanning is batched so no more than maxConcurrent probes are in flight at once.

Choosing the right entry point

import 'package:tom_basics_network/tom_basics_network.dart';

// Best effort — null when nothing answers.
final maybe = await ServerDiscovery.discover();

// Every server on the network (e.g. to pick or list them).
final all = await ServerDiscovery.discoverAll();
print('Found ${all.length} server(s).');

<a id="fail-loudly-when-no-server-is-found"></a> When a missing server is a hard error, discoverOrThrow saves you the null-check:

import 'package:tom_basics_network/tom_basics_network.dart';

try {
  final server = await ServerDiscovery.discoverOrThrow();
  print('Connected to ${server.serverUrl}');
} on DiscoveryFailedException catch (e) {
  print('Discovery failed: $e');
}

Tuning the scan

DiscoveryOptions controls every knob; all fields have defaults and copyWith makes per-call tweaks ergonomic:

import 'package:tom_basics_network/tom_basics_network.dart';

const base = DiscoveryOptions(
  port: 8080,           // default 19880
  scanSubnet: false,    // localhost + local IPs only — fast, no subnet sweep
);

final verbose = base.copyWith(
  timeout: const Duration(seconds: 1),
  logger: print,       // trace each candidate as it is probed
);
FieldDefaultPurpose
port19880TCP port probed on each host
timeout 500 ms Per-host connection/response timeout
scanSubnet true Whether to sweep the /24 after local checks
maxConcurrent 20 Max simultaneous probes during a subnet sweep
statusPath /status Path appended to each candidate URL
logger null Optional void Function(String) progress trace
statusValidator null Optional extra check on the parsed status map

Use statusValidator to reject servers that answer but aren't the one you want — for example, requiring a specific service name:

import 'package:tom_basics_network/tom_basics_network.dart';

const options = DiscoveryOptions(
  statusValidator: _isLedgerService,
);

bool _isLedgerService(Map<String, dynamic> status) =>
    status['service'] == 'tom_dist_ledger';

Reading a discovered server

DiscoveredServer pairs the URL with the parsed status payload and surfaces the common fields as typed getters:

import 'package:tom_basics_network/tom_basics_network.dart';

void describe(DiscoveredServer s) {
  print('URL:     ${s.serverUrl}');
  print('Service: ${s.service}');   // status['service']
  print('Version: ${s.version}');   // status['version']
  print('Port:    ${s.port}');      // status['port']
  // Anything else is in s.status, the raw decoded JSON map.
}

Working with addresses directly

The two address helpers that drive the scan are public, so you can reuse them for your own probing:

import 'package:tom_basics_network/tom_basics_network.dart';

Future<void> main() async {
  final mine = await ServerDiscovery.getLocalIpAddresses();
  print('This host: $mine');

  // Every other host in a /24, excluding the address you pass in.
  final peers = ServerDiscovery.getSubnetAddresses('192.168.1.100');
  print('${peers.length} candidates'); // 253: .1 … .254 minus .100
}

---

Architecture

package:tom_basics_network/tom_basics_network.dart   (single entry point)
        │
        ├── src/http_retry.dart
        │     withRetry<T>()  ──uses──▶ RetryConfig ──holds──▶ delays + onRetry
        │         │
        │         ├─ classifies errors  (SocketException, TimeoutException, …)
        │         ├─ RetryableResponse   (status-code helper on http.Response)
        │         └─ throws RetryExhaustedException when the budget is spent
        │
        └── src/server_discovery.dart
              ServerDiscovery (static)
                  │  discover / discoverOrThrow / discoverAll
                  ├─ getLocalIpAddresses / getSubnetAddresses
                  ├─ probes GET <host>:<port><statusPath>
                  └─ DiscoveryOptions → DiscoveredServer | DiscoveryFailedException

The package exposes no mutable singletons and no initialisation step. Both subsystems are pure functions over immutable configuration, which keeps them trivially testable and isolate-safe.

TypeRole
withRetry<T> The retry loop; the only stateful logic, scoped to one call
RetryConfig Immutable retry policy (delays + onRetry callback)
RetryExhaustedException Carries the final error, stack trace, attempt count
RetryableResponseExtension classifying HTTP status codes
ServerDiscoveryStatic façade over the probe/scan logic
DiscoveryOptionsImmutable scan policy with copyWith
DiscoveredServerA result: URL plus parsed status map
DiscoveryFailedException Raised by discoverOrThrow on an empty scan

---

Ecosystem

tom_basics_network is one of the tom_ai/basics foundation packages:

  • tom_basics — exceptions, logging, the platform seam, and

the runtime model that the rest of the basics layer builds on. - tom_basics_console — the standalone/server platform implementation, including an IO-based HTTP client that pairs naturally with withRetry.

This package depends on neither — it stands alone on package:http and dart:io — but it is built to sit alongside them in a Tom application. A typical server uses tom_basics_console for its platform layer, withRetry to harden its outbound calls, and ServerDiscovery to locate its peers.

---

Further documentation

— runnable demonstration of the retry schedule. - test/tom_basics_network_test.dart — the behavioural specification: retry success/exhaustion, error classification, subnet maths, and option defaults. - ../README.md — the tom_ai/basics package map.

---

Status

  • Version: 1.0.1
  • Tests: 12 passing (dart test) — RetryConfig,

RetryExhaustedException, withRetry, and ServerDiscovery. - Analysis: clean under package:lints (dart analyze — no issues). - Platforms: any Dart runtime with dart:io (desktop, server, CLI).

License
BSD 3-Clause License

Copyright (c) 2024-2026, Peter Nicolai Alexis Kyaw
Find me on LinkedIn under Alexis Kyaw
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
   list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
   contributors may be used to endorse or promote products derived from
   this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.