@autobusal/providers 1.29.4 → 1.30.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.30.0
4
+
5
+ ### Added
6
+
7
+ - **`BootstrapError` tells a transient failure from a real one and recovers
8
+ by itself.** A rate limit or a dropped connection is not a system error,
9
+ and "something broke on our side" is wrong twice for either: it blames the
10
+ wrong thing, and it hands the visitor a reload button for a situation where
11
+ reloading immediately makes it worse. 429/503/504 and a request with no
12
+ response at all now get their own wording and an automatic retry that backs
13
+ off 5s, 10s, 20s, 30s.
14
+
3
15
  ## 1.29.4
4
16
 
5
17
  ### Added
@@ -35,11 +35,63 @@ interface Props {
35
35
  * *into*. A full reload is the honest recovery, and it is what a visitor
36
36
  * would do anyway.
37
37
  */
38
- class BootstrapError extends React.Component<Props, { failed: boolean }> {
39
- state = { failed: false };
38
+ /**
39
+ * Is this a failure that fixes itself?
40
+ *
41
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
42
+ *
43
+ * A rate limit and a dropped connection are not system errors, and telling
44
+ * somebody "something broke on our side" for either is wrong twice over: it
45
+ * blames the wrong thing, and it hands them a reload button for a situation
46
+ * where reloading immediately is the one move that makes it worse.
47
+ *
48
+ * Read off the axios error rather than the message text, which is not
49
+ * stable. A 429 carries a status; a network failure carries none at all,
50
+ * which is what distinguishes it from a server that answered with a fault.
51
+ */
52
+ const transient = (error: any): 'busy' | 'offline' | null => {
53
+ const status = error?.response?.status ?? error?.status;
54
+
55
+ if (status === 429) {
56
+ return 'busy';
57
+ }
58
+
59
+ if (status === 503 || status === 504) {
60
+ return 'busy';
61
+ }
62
+
63
+ // No response at all - DNS, offline, connection refused, CORS preflight
64
+ // that never landed. Distinct from a server that replied with a 500.
65
+ if (error?.isAxiosError && !error?.response) {
66
+ return 'offline';
67
+ }
68
+
69
+ return null;
70
+ };
71
+
72
+ /**
73
+ * How long to wait before trying again.
74
+ *
75
+ * The rate limiter's window is a minute, so a few seconds is usually enough
76
+ * and a full minute feels broken. Backs off on each successive failure so a
77
+ * genuinely overloaded server is not hammered by every open tab at once.
78
+ */
79
+ const DELAYS = [ 5, 10, 20, 30 ];
80
+
81
+ interface State {
82
+ failed: boolean
83
+ kind: 'busy' | 'offline' | null
84
+ attempt: number
85
+ countdown: number
86
+ }
40
87
 
41
- static getDerivedStateFromError() {
42
- return { failed: true };
88
+ class BootstrapError extends React.Component<Props, State> {
89
+ state: State = { failed: false, kind: null, attempt: 0, countdown: 0 };
90
+
91
+ private timer?: ReturnType<typeof setInterval>;
92
+
93
+ static getDerivedStateFromError(error: Error) {
94
+ return { failed: true, kind: transient(error) };
43
95
  }
44
96
 
45
97
  componentDidCatch(error: Error, info: React.ErrorInfo): void {
@@ -47,6 +99,45 @@ class BootstrapError extends React.Component<Props, { failed: boolean }> {
47
99
  // knows the app failed to start, and a white page with a `log` entry is
48
100
  // easy to miss when somebody finally does open the console.
49
101
  console.error('[bootstrap] the application could not start', error, info);
102
+
103
+ /*
104
+ * A transient failure retries itself. The visitor did nothing wrong and
105
+ * has nothing useful to do, so asking them to press a button is asking
106
+ * them to do our waiting for us - and the thing they would press it for
107
+ * is a rate limit that clears on its own.
108
+ */
109
+ if (transient(error)) {
110
+ this.countdownTo(this.state.attempt);
111
+ }
112
+ }
113
+
114
+ componentWillUnmount(): void {
115
+ if (this.timer) {
116
+ clearInterval(this.timer);
117
+ }
118
+ }
119
+
120
+ private countdownTo(attempt: number): void {
121
+ const seconds = DELAYS[Math.min(attempt, DELAYS.length - 1)];
122
+
123
+ this.setState({ countdown: seconds });
124
+
125
+ this.timer = setInterval(() => {
126
+ this.setState(state => {
127
+ if (state.countdown > 1) {
128
+ return { ...state, countdown: state.countdown - 1 };
129
+ }
130
+
131
+ clearInterval(this.timer);
132
+
133
+ // A full reload rather than resetting this boundary: whatever
134
+ // suspended did so during bootstrap, so there is no reliable state
135
+ // to retry INTO - the same reasoning as the button below.
136
+ window.location.reload();
137
+
138
+ return { ...state, countdown: 0 };
139
+ });
140
+ }, 1000);
50
141
  }
51
142
 
52
143
  render() {
@@ -59,6 +150,26 @@ class BootstrapError extends React.Component<Props, { failed: boolean }> {
59
150
  const language = getLanguage('en');
60
151
  const copy = errors[language as keyof typeof errors] ?? errors.en;
61
152
 
153
+ /*
154
+ * Edited: Ferjolt Ozuni - Date: 2026-08-07
155
+ * A transient failure gets its own words and its own countdown. The
156
+ * generic system error keeps the reload button, because for a real
157
+ * fault there is nothing to wait for.
158
+ */
159
+ const { kind, countdown } = this.state;
160
+
161
+ const title = kind === 'busy' ? (copy as any).busy ?? copy.system : copy.system;
162
+
163
+ const detail = kind === 'busy'
164
+ ? ((copy as any).busy_detail ?? copy.system_detail)
165
+ : kind === 'offline'
166
+ ? ((copy as any).offline_detail ?? copy.system_detail)
167
+ : copy.system_detail;
168
+
169
+ const message = kind
170
+ ? String(detail).replace('{n}', String(countdown))
171
+ : detail;
172
+
62
173
  return (
63
174
  <div
64
175
  role="alert"
@@ -75,11 +186,11 @@ class BootstrapError extends React.Component<Props, { failed: boolean }> {
75
186
  >
76
187
  <div style={ { maxWidth: '460px', textAlign: 'center' } }>
77
188
  <h1 style={ { fontSize: '20px', lineHeight: 1.4, margin: '0 0 12px' } }>
78
- { copy.system }
189
+ { title }
79
190
  </h1>
80
191
 
81
192
  <p style={ { fontSize: '15px', lineHeight: 1.6, margin: '0 0 24px', opacity: 0.8 } }>
82
- { copy.system_detail }
193
+ { message }
83
194
  </p>
84
195
 
85
196
  <button
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/providers",
3
- "version": "1.29.4",
3
+ "version": "1.30.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"