@kevludwig/lexware-office 0.3.0 → 0.4.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/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.
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,15 @@ export function query(params) {
54
55
  return rendered ? `?${rendered}` : "";
55
56
  }
56
57
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
58
+ /** The server's Retry-After in ms (seconds or an HTTP date), capped at 10 s; null without one. */
59
+ function retryAfterMs(response) {
60
+ const header = response.headers.get("retry-after")?.trim();
61
+ if (!header)
62
+ return null;
63
+ const seconds = Number(header);
64
+ const ms = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(header) - Date.now();
65
+ return Number.isFinite(ms) ? Math.min(Math.max(ms, 0), 10_000) : null;
66
+ }
57
67
  export function createLexwareClient(options) {
58
68
  if (!options.apiKey?.trim())
59
69
  throw new Error("Lexware Office: apiKey fehlt.");
@@ -63,6 +73,7 @@ export function createLexwareClient(options) {
63
73
  const timeoutMs = options.timeoutMs ?? 20_000;
64
74
  const uploadTimeoutMs = options.uploadTimeoutMs ?? 120_000;
65
75
  const backoffMs = options.rateLimitBackoffMs ?? 1_000;
76
+ const retries = options.rateLimitRetries ?? 3;
66
77
  const fetchImpl = options.fetch ?? fetch;
67
78
  // The queue: each request waits for the previous one to start plus the
68
79
  // spacing. The time limit starts when the request fires, not when queued.
@@ -81,9 +92,9 @@ export function createLexwareClient(options) {
81
92
  return run;
82
93
  }
83
94
  /**
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.
95
+ * One request, queued, retried on 429 — then the API demonstrably did not
96
+ * run it. A transport error after a POST is never retried: the voucher may
97
+ * well exist already.
87
98
  */
88
99
  async function send(path, init, limitMs = timeoutMs) {
89
100
  const method = init.method ?? "GET";
@@ -96,8 +107,9 @@ export function createLexwareClient(options) {
96
107
  });
97
108
  });
98
109
  let response = await fire();
99
- if (response.status === 429) {
100
- await sleep(backoffMs);
110
+ for (let attempt = 1; response.status === 429 && attempt <= retries; attempt++) {
111
+ await response.body?.cancel().catch(() => undefined);
112
+ await sleep(retryAfterMs(response) ?? backoffMs * attempt);
101
113
  response = await fire();
102
114
  }
103
115
  if (!response.ok) {
@@ -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.0",
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",