Files
accounted/lib/providers/with-provider-call.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

235 lines
7.9 KiB
TypeScript

/**
* Wraps a single external HTTP call to a third-party provider (Fortnox, Bokio,
* Visma, Briox, BL/Björn Lundén, Enable Banking, etc.) with structured
* logging and code-mapped errors.
*
* Translates HTTP failures and network errors into ProviderCallError, which
* the route wrapper's errorResponse() recognises as a structured code. This
* keeps the user message + remediation consistent across providers without
* each call site having to repeat the mapping.
*/
import { createLogger, type Logger } from '@/lib/logger'
export type ProviderCallErrorCode =
| 'PROVIDER_AUTH_EXPIRED'
| 'PROVIDER_LICENSE_MISSING'
| 'PROVIDER_RATE_LIMITED'
| 'PROVIDER_UNREACHABLE'
| 'PROVIDER_UPSTREAM_ERROR'
export class ProviderCallError extends Error {
readonly code: ProviderCallErrorCode
readonly provider: string
readonly status?: number
readonly retryAfterSeconds?: number
constructor(
code: ProviderCallErrorCode,
provider: string,
message: string,
extras: { status?: number; retryAfterSeconds?: number } = {},
) {
super(message)
this.name = 'ProviderCallError'
this.code = code
this.provider = provider
this.status = extras.status
this.retryAfterSeconds = extras.retryAfterSeconds
}
}
export function isProviderCallError(err: unknown): err is ProviderCallError {
return err instanceof ProviderCallError
}
interface ProviderCallOptions {
/** Provider id ('fortnox', 'bokio', 'visma', etc.). */
provider: string
/** Short label for what this call does, e.g. 'fetch_invoices'. */
operation: string
/** Optional logger; if omitted a `provider/<provider>` logger is created. */
log?: Logger
/** Extra context merged into the log line. */
context?: Record<string, unknown>
}
/**
* Run an async callable that performs the actual HTTP request and translate
* its failures. The callable should throw a `Response` (preferred) or a
* regular Error; ProviderCallError is mapped from the response status.
*
* Example:
* await withProviderCall(
* { provider: 'fortnox', operation: 'fetch_invoices' },
* async () => {
* const res = await fetch(url, { headers })
* if (!res.ok) throw res
* return res.json()
* },
* )
*/
export async function withProviderCall<T>(
options: ProviderCallOptions,
call: () => Promise<T>,
): Promise<T> {
const log = (options.log ?? createLogger(`provider/${options.provider}`)).child({
provider: options.provider,
providerOp: options.operation,
...options.context,
})
const start = Date.now()
try {
const result = await call()
log.info('provider call ok', { latencyMs: Date.now() - start })
return result
} catch (raw) {
const latencyMs = Date.now() - start
if (raw instanceof Response) {
const mapped = mapResponseError(raw, options.provider)
log.error('provider call failed (http)', mapped, {
latencyMs,
status: raw.status,
})
throw mapped
}
if (raw instanceof ProviderCallError) {
log.error('provider call failed', raw, { latencyMs })
throw raw
}
if (raw instanceof Error && isNetworkError(raw)) {
const wrapped = new ProviderCallError(
'PROVIDER_UNREACHABLE',
options.provider,
raw.message,
)
log.error('provider call unreachable', wrapped, { latencyMs })
throw wrapped
}
// Unknown shape: re-throw so the outer handler can decide. We still log it.
log.error('provider call failed (unknown)', raw as Error, { latencyMs })
throw raw
}
}
function mapResponseError(res: Response, provider: string): ProviderCallError {
if (res.status === 401 || res.status === 403) {
return new ProviderCallError(
'PROVIDER_AUTH_EXPIRED',
provider,
`Provider authentication failed: ${res.status} ${res.statusText}`,
{ status: res.status },
)
}
if (res.status === 429) {
const retryAfter = parseRetryAfter(res.headers.get('retry-after'))
return new ProviderCallError(
'PROVIDER_RATE_LIMITED',
provider,
`Provider rate limit hit: ${res.status} ${res.statusText}`,
{ status: res.status, retryAfterSeconds: retryAfter },
)
}
if (res.status >= 500) {
return new ProviderCallError(
'PROVIDER_UPSTREAM_ERROR',
provider,
`Provider upstream error: ${res.status} ${res.statusText}`,
{ status: res.status },
)
}
// 4xx other than 401/403/429 is application-level: surface as upstream so
// the user gets a meaningful Swedish message; the actual cause is in logs.
return new ProviderCallError(
'PROVIDER_UPSTREAM_ERROR',
provider,
`Provider rejected request: ${res.status} ${res.statusText}`,
{ status: res.status },
)
}
function parseRetryAfter(value: string | null): number | undefined {
if (!value) return undefined
const n = parseInt(value, 10)
return Number.isFinite(n) ? n : undefined
}
function isNetworkError(err: Error): boolean {
// node-undici throws TypeError('fetch failed') with a `cause` for DNS/TCP issues.
if (err.name === 'TypeError' && /fetch failed/i.test(err.message)) return true
if (err.name === 'AbortError') return true
// Known undici error codes
const cause = (err as Error & { cause?: { code?: string } }).cause
if (cause?.code && ['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(cause.code)) {
return true
}
return false
}
/**
* Classify an error from a provider client (Fortnox/Bokio/Visma/Briox/BL) into
* a structured error code. Reads `statusCode` (Fortnox client) or `status`
* (other clients) off the thrown error and maps:
*
* 401/403 → PROVIDER_AUTH_EXPIRED
* 429 → PROVIDER_RATE_LIMITED
* 5xx → PROVIDER_UPSTREAM_ERROR
* network → PROVIDER_UNREACHABLE
* other → null (caller falls back to its domain-specific code, e.g.
* `PROVIDER_SIE_FETCH_FAILED`)
*
* Use at the boundary where a provider call's failure becomes a user-facing
* response. Lets the toast show a specific Swedish message ("Anslutningen har
* gått ut. Återanslut för att fortsätta." vs. "Försök igen om en stund.")
* instead of the same generic message for every cause.
*/
export function classifyProviderError(error: unknown): ProviderCallErrorCode | null {
if (error instanceof ProviderCallError) {
return error.code
}
if (!(error instanceof Error)) return null
const status =
(error as Error & { statusCode?: number; status?: number }).statusCode ??
(error as Error & { statusCode?: number; status?: number }).status
if (typeof status === 'number') {
if (status === 401 || status === 403) return 'PROVIDER_AUTH_EXPIRED'
if (status === 429) return 'PROVIDER_RATE_LIMITED'
if (status >= 500) return 'PROVIDER_UPSTREAM_ERROR'
}
if (isNetworkError(error)) return 'PROVIDER_UNREACHABLE'
return null
}
/**
* True when a provider token/OAuth failure means the integration license is
* missing or inactive, NOT an ordinary expired/revoked grant.
*
* Fortnox answers its token endpoint with `error_missing_license` when the
* customer's Fortnox account no longer carries the integration license. The
* stored refresh token cannot be revived by re-authorizing: re-auth loops until
* the customer re-orders the "Fortnox Integration" add-on. Distinguishing this
* from a plain dead token lets callers say "activate the license, then
* reconnect" instead of a bare "reconnect" that just fails again.
*
* Matches on the raw provider message string because the underlying refresh
* helpers bake the body into the Error message; deliberately does NOT match
* `invalid_grant` (that IS a revivable reconnect → PROVIDER_AUTH_EXPIRED).
*/
export function isMissingLicenseError(message: string): boolean {
const haystack = message.toLowerCase()
return (
haystack.includes('error_missing_license') ||
haystack.includes('missing_license') ||
haystack.includes('missing license') ||
haystack.includes('not have enough licenses')
)
}