@kevludwig/lexware-office 0.3.0 → 0.4.1
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/README.md +3 -2
- package/dist/client.d.ts +7 -4
- package/dist/client.js +39 -8
- package/dist/contacts.d.ts +2 -2
- package/dist/contacts.js +2 -2
- package/dist/reminder-mail.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ One client is one rate-limit queue: requests are spaced (the API allows two per
|
|
|
27
27
|
| `appUrl` | `https://app.lexware.de` | Base of the links `voucherUrl()` builds |
|
|
28
28
|
| `minSpacingMs` | `550` | Spacing between two requests |
|
|
29
29
|
| `timeoutMs` | `20000` | Per request, counted from when it fires |
|
|
30
|
-
| `rateLimitBackoffMs` | `1000` | Wait before
|
|
30
|
+
| `rateLimitBackoffMs` | `1000` | Wait before a retry after a 429 without `Retry-After`, growing with each retry |
|
|
31
|
+
| `rateLimitRetries` | `3` | How often a request answered with 429 is sent again; `Retry-After` is honoured (at most 10 s) |
|
|
31
32
|
| `fetch` | global `fetch` | Your own implementation, e.g. in tests |
|
|
32
33
|
|
|
33
34
|
A failed request throws `LexwareApiError` with `status` and `responseBody`; `describeError(error)` turns it into a sentence worth showing a person — a 403 says which permission the key lacks, a 406 what the API usually means by it.
|
|
@@ -89,7 +90,7 @@ The ledger needs somewhere to keep one small JSON record per invoice. `JsonStore
|
|
|
89
90
|
|
|
90
91
|
## Tests
|
|
91
92
|
|
|
92
|
-
`npm test` runs
|
|
93
|
+
`npm test` runs 56 tests against a fake API — no account, no network. `test/fake-api.ts` shows the pattern: routes answer by method and path, and every call is recorded.
|
|
93
94
|
|
|
94
95
|
## Not affiliated with Lexware
|
|
95
96
|
|
package/dist/client.d.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One client per account: the API allows two requests per second for the
|
|
5
5
|
* whole account, so every call of a client goes through one serial queue with
|
|
6
|
-
* a minimum spacing.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* a minimum spacing. Retries on 429 — honouring Retry-After — cover other
|
|
7
|
+
* clients on the same account: serverless instances, a second app. Share one
|
|
8
|
+
* client per process; separate clients do not know about each other's
|
|
9
|
+
* requests, the retries only soften that.
|
|
9
10
|
*/
|
|
10
11
|
export declare const DEFAULT_BASE_URL = "https://api.lexware.io/v1";
|
|
11
12
|
/** Where a voucher opens in the Lexware Office web app. */
|
|
@@ -27,8 +28,10 @@ export interface LexwareClientOptions {
|
|
|
27
28
|
* a file that arrives anyway. Default: 120 000.
|
|
28
29
|
*/
|
|
29
30
|
uploadTimeoutMs?: number;
|
|
30
|
-
/** Wait before retrying a request answered with 429, in ms. Default: 1 000. */
|
|
31
|
+
/** Wait before retrying a request answered with 429 without Retry-After, in ms; grows with each retry. Default: 1 000. */
|
|
31
32
|
rateLimitBackoffMs?: number;
|
|
33
|
+
/** How often a request answered with 429 is sent again. Default: 3. */
|
|
34
|
+
rateLimitRetries?: number;
|
|
32
35
|
/** A fetch implementation, e.g. for tests. Default: the global fetch. */
|
|
33
36
|
fetch?: typeof fetch;
|
|
34
37
|
}
|
package/dist/client.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One client per account: the API allows two requests per second for the
|
|
5
5
|
* whole account, so every call of a client goes through one serial queue with
|
|
6
|
-
* a minimum spacing.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* a minimum spacing. Retries on 429 — honouring Retry-After — cover other
|
|
7
|
+
* clients on the same account: serverless instances, a second app. Share one
|
|
8
|
+
* client per process; separate clients do not know about each other's
|
|
9
|
+
* requests, the retries only soften that.
|
|
9
10
|
*/
|
|
10
11
|
export const DEFAULT_BASE_URL = "https://api.lexware.io/v1";
|
|
11
12
|
/** Where a voucher opens in the Lexware Office web app. */
|
|
@@ -54,6 +55,33 @@ export function query(params) {
|
|
|
54
55
|
return rendered ? `?${rendered}` : "";
|
|
55
56
|
}
|
|
56
57
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
+
/** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
|
|
59
|
+
function abortableSleep(ms, signal) {
|
|
60
|
+
if (!signal)
|
|
61
|
+
return sleep(ms).then(() => undefined);
|
|
62
|
+
if (signal.aborted)
|
|
63
|
+
return Promise.reject(signal.reason);
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const onAbort = () => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
reject(signal.reason);
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
signal.removeEventListener("abort", onAbort);
|
|
71
|
+
resolve();
|
|
72
|
+
}, ms);
|
|
73
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** The server's Retry-After in ms (seconds or an HTTP date), capped at 10 s; null without one. */
|
|
77
|
+
function retryAfterMs(response) {
|
|
78
|
+
const header = response.headers.get("retry-after")?.trim();
|
|
79
|
+
if (!header)
|
|
80
|
+
return null;
|
|
81
|
+
const seconds = Number(header);
|
|
82
|
+
const ms = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(header) - Date.now();
|
|
83
|
+
return Number.isFinite(ms) ? Math.min(Math.max(ms, 0), 10_000) : null;
|
|
84
|
+
}
|
|
57
85
|
export function createLexwareClient(options) {
|
|
58
86
|
if (!options.apiKey?.trim())
|
|
59
87
|
throw new Error("Lexware Office: apiKey fehlt.");
|
|
@@ -63,6 +91,7 @@ export function createLexwareClient(options) {
|
|
|
63
91
|
const timeoutMs = options.timeoutMs ?? 20_000;
|
|
64
92
|
const uploadTimeoutMs = options.uploadTimeoutMs ?? 120_000;
|
|
65
93
|
const backoffMs = options.rateLimitBackoffMs ?? 1_000;
|
|
94
|
+
const retries = options.rateLimitRetries ?? 3;
|
|
66
95
|
const fetchImpl = options.fetch ?? fetch;
|
|
67
96
|
// The queue: each request waits for the previous one to start plus the
|
|
68
97
|
// spacing. The time limit starts when the request fires, not when queued.
|
|
@@ -81,9 +110,9 @@ export function createLexwareClient(options) {
|
|
|
81
110
|
return run;
|
|
82
111
|
}
|
|
83
112
|
/**
|
|
84
|
-
* One request, queued,
|
|
85
|
-
*
|
|
86
|
-
*
|
|
113
|
+
* One request, queued, retried on 429 — then the API demonstrably did not
|
|
114
|
+
* run it. A transport error after a POST is never retried: the voucher may
|
|
115
|
+
* well exist already.
|
|
87
116
|
*/
|
|
88
117
|
async function send(path, init, limitMs = timeoutMs) {
|
|
89
118
|
const method = init.method ?? "GET";
|
|
@@ -96,8 +125,10 @@ export function createLexwareClient(options) {
|
|
|
96
125
|
});
|
|
97
126
|
});
|
|
98
127
|
let response = await fire();
|
|
99
|
-
|
|
100
|
-
await
|
|
128
|
+
for (let attempt = 1; response.status === 429 && attempt <= retries; attempt++) {
|
|
129
|
+
await response.body?.cancel().catch(() => undefined);
|
|
130
|
+
// The caller's cancellation also ends the wait between retries.
|
|
131
|
+
await abortableSleep(retryAfterMs(response) ?? backoffMs * attempt, init.signal);
|
|
101
132
|
response = await fire();
|
|
102
133
|
}
|
|
103
134
|
if (!response.ok) {
|
package/dist/contacts.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Contacts — customers and vendors alike.
|
|
3
3
|
*
|
|
4
|
-
* Search is fuzzy on the API side: "
|
|
5
|
-
*
|
|
4
|
+
* Search is fuzzy on the API side: "Beispiel" finds "Beispiel GmbH",
|
|
5
|
+
* "Müller" may find three. Functions here return candidates and leave
|
|
6
6
|
* the choice to the caller; guessing one of several is the worse mistake.
|
|
7
7
|
*/
|
|
8
8
|
import { type LexwareClient } from "./client.ts";
|
package/dist/contacts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Contacts — customers and vendors alike.
|
|
3
3
|
*
|
|
4
|
-
* Search is fuzzy on the API side: "
|
|
5
|
-
*
|
|
4
|
+
* Search is fuzzy on the API side: "Beispiel" finds "Beispiel GmbH",
|
|
5
|
+
* "Müller" may find three. Functions here return candidates and leave
|
|
6
6
|
* the choice to the caller; guessing one of several is the worse mistake.
|
|
7
7
|
*/
|
|
8
8
|
import { query } from "./client.js";
|
package/dist/reminder-mail.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export declare class MailNotSentError extends Error {
|
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
export interface ReminderSender {
|
|
45
|
-
/** Signature name, e.g. "
|
|
45
|
+
/** Signature name, e.g. "Max Muster". */
|
|
46
46
|
name: string;
|
|
47
47
|
/** Lines under the name, e.g. the company and town. */
|
|
48
48
|
signature?: readonly string[];
|
package/package.json
CHANGED