@tikoci/quickchr 0.4.5 → 0.4.7

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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Arch, Channel } from "./types.ts";
6
6
  import { CHANNELS, QuickCHRError } from "./types.ts";
7
- import { fetchResilient } from "./net.ts";
7
+ import { fetchResilient, isConnectionFailure } from "./net.ts";
8
8
 
9
9
  const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
10
10
  const DOWNLOAD_BASE = "https://download.mikrotik.com/routeros";
@@ -65,35 +65,112 @@ export function provisioningSupportHint(minimumVersion = MIN_PROVISION_VERSION):
65
65
  return `Use --channel long-term or --version ${minimumVersion}+ for ${PROVISIONING_FEATURE_SUMMARY}, or keep the older version without provisioning options.`;
66
66
  }
67
67
 
68
- /** Fetch the latest version for a given channel from MikroTik's upgrade server. */
69
- export async function resolveVersion(channel: Channel): Promise<string> {
70
- const url = `${UPGRADE_BASE}.${channel}`;
68
+ /** Attempts for a connection-class failure while resolving a channel version. */
69
+ export const VERSION_RESOLVE_MAX_ATTEMPTS = 3;
70
+
71
+ /** Backoff base: attempt N waits N × this before N+1. Mirrors `downloadToFile`. */
72
+ const VERSION_RESOLVE_RETRY_MS = 2000;
73
+
74
+ export interface ResolveVersionOptions {
75
+ /** Attempts for connection-class failures. Defaults to {@link VERSION_RESOLVE_MAX_ATTEMPTS}. */
76
+ maxAttempts?: number;
77
+ /**
78
+ * Override the backoff base. A test lever, like `downloadToFile`'s `stallMs`:
79
+ * the real backoff spends 6 s of wall clock exhausting the default attempts.
80
+ * Production callers should not set it.
81
+ */
82
+ retryDelayMs?: number;
83
+ }
71
84
 
72
- const response = await fetchResilient(url);
73
- if (!response.ok) {
74
- throw new QuickCHRError(
75
- "DOWNLOAD_FAILED",
76
- `Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
77
- );
78
- }
85
+ /** A whole count of at least 1, or `fallback` for anything that isn't one. */
86
+ function positiveInt(value: number | undefined, fallback: number): number {
87
+ if (value === undefined || !Number.isFinite(value)) return fallback;
88
+ return Math.max(1, Math.floor(value));
89
+ }
90
+
91
+ /** A finite, non-negative delay in ms, or `fallback` for anything that isn't one. */
92
+ function nonNegativeMs(value: number | undefined, fallback: number): number {
93
+ if (value === undefined || !Number.isFinite(value)) return fallback;
94
+ return Math.max(0, value);
95
+ }
79
96
 
80
- const text = await response.text();
81
- // Response format: "7.22.1 1774276515" (version + unix timestamp)
82
- const version = text.trim().split(/\s+/)[0]?.trim();
83
- if (!version || !isValidVersion(version)) {
84
- throw new QuickCHRError(
85
- "INVALID_VERSION",
86
- `Unexpected version format for channel "${channel}": "${text.trim()}"`,
87
- );
97
+ /**
98
+ * Fetch the latest version for a given channel from MikroTik's upgrade server.
99
+ *
100
+ * Retried on a connection-class failure only — a transient refusal at
101
+ * `upgrade.mikrotik.com` used to fail a whole `quickchr start`, since this sits
102
+ * on the critical path of `start()`, the wizard and the CLI while only the
103
+ * download path had a retry policy (#121). An HTTP answer is the server's verdict
104
+ * and stays terminal on the first attempt: a 404 for a bad channel must fail fast.
105
+ */
106
+ export async function resolveVersion(
107
+ channel: Channel,
108
+ opts: ResolveVersionOptions = {},
109
+ ): Promise<string> {
110
+ const url = `${UPGRADE_BASE}.${channel}`;
111
+ // Sanitized, not just clamped: these are public options, and `Math.max(1, NaN)`
112
+ // is NaN — the loop would never run, and the failure would read "all NaN
113
+ // attempts: undefined" instead of doing anything.
114
+ const maxAttempts = positiveInt(opts.maxAttempts, VERSION_RESOLVE_MAX_ATTEMPTS);
115
+ const retryDelayMs = nonNegativeMs(opts.retryDelayMs, VERSION_RESOLVE_RETRY_MS);
116
+ let lastError: unknown;
117
+
118
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
119
+ let text: string;
120
+ try {
121
+ const response = await fetchResilient(url);
122
+ if (!response.ok) {
123
+ // An HTTP answer is the server's verdict. Thrown as a QuickCHRError so
124
+ // the catch below can tell it from a transport failure and let it out.
125
+ throw new QuickCHRError(
126
+ "DOWNLOAD_FAILED",
127
+ `Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
128
+ );
129
+ }
130
+ // Inside the try: the body can fail *after* the headers arrive (a reset
131
+ // mid-response), which is a transport failure like any other and must be
132
+ // retried rather than escaping the loop unwrapped.
133
+ text = await response.text();
134
+ } catch (err) {
135
+ // Only "could not reach the host at all" is retried. An HTTP verdict, an
136
+ // abort (the caller gave up) or a real bug surfaces immediately.
137
+ if (err instanceof QuickCHRError) throw err;
138
+ if (!isConnectionFailure(err)) throw err;
139
+ lastError = err;
140
+ if (attempt < maxAttempts) await Bun.sleep(attempt * retryDelayMs);
141
+ continue;
142
+ }
143
+
144
+ // Parsing is terminal: the server answered, and answering with something
145
+ // unparseable is not a condition another attempt can improve.
146
+ // Response format: "7.22.1 1774276515" (version + unix timestamp)
147
+ const version = text.trim().split(/\s+/)[0]?.trim();
148
+ if (!version || !isValidVersion(version)) {
149
+ throw new QuickCHRError(
150
+ "INVALID_VERSION",
151
+ `Unexpected version format for channel "${channel}": "${text.trim()}"`,
152
+ );
153
+ }
154
+
155
+ return version;
88
156
  }
89
157
 
90
- return version;
158
+ // Says "1 attempt" rather than "all 1 attempts" — these land in CI logs.
159
+ const tries = maxAttempts === 1 ? "1 attempt" : `all ${maxAttempts} attempts`;
160
+ throw new QuickCHRError(
161
+ "DOWNLOAD_FAILED",
162
+ `Failed to fetch version for channel "${channel}" on ${tries}: ` +
163
+ `${lastError instanceof Error ? lastError.message : String(lastError)}`,
164
+ `Check network access to ${new URL(url).hostname}, or pass an explicit --version to skip channel resolution.`,
165
+ );
91
166
  }
92
167
 
93
168
  /** Fetch latest versions for all channels in parallel. */
94
- export async function resolveAllVersions(): Promise<Record<Channel, string>> {
169
+ export async function resolveAllVersions(
170
+ opts: ResolveVersionOptions = {},
171
+ ): Promise<Record<Channel, string>> {
95
172
  const results = await Promise.all(
96
- CHANNELS.map(async (ch) => [ch, await resolveVersion(ch)] as const),
173
+ CHANNELS.map(async (ch) => [ch, await resolveVersion(ch, opts)] as const),
97
174
  );
98
175
  return Object.fromEntries(results) as Record<Channel, string>;
99
176
  }