@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 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 the retry after a 429 |
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 38 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
+ `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. A single retry on 429 covers the case that something else
7
- * uses the same account at the same time. Share one client instead of creating
8
- * one per call — separate clients do not know about each other's requests.
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. A single retry on 429 covers the case that something else
7
- * uses the same account at the same time. Share one client instead of creating
8
- * one per call — separate clients do not know about each other's requests.
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, with a single retry on 429 — then the API
85
- * demonstrably did not run it. A transport error after a POST is never
86
- * retried: the voucher may well exist already.
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
- if (response.status === 429) {
100
- await sleep(backoffMs);
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) {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Contacts — customers and vendors alike.
3
3
  *
4
- * Search is fuzzy on the API side: "Nordlicht" finds "Nordlicht Consulting
5
- * GmbH", "Müller" may find three. Functions here return candidates and leave
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: "Nordlicht" finds "Nordlicht Consulting
5
- * GmbH", "Müller" may find three. Functions here return candidates and leave
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";
@@ -42,7 +42,7 @@ export declare class MailNotSentError extends Error {
42
42
  });
43
43
  }
44
44
  export interface ReminderSender {
45
- /** Signature name, e.g. "Kevin Ludwig". */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevludwig/lexware-office",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Typed client and domain logic for the Lexware Office Public API: contacts, receivables, payment reminder rules.",
5
5
  "type": "module",
6
6
  "license": "MIT",