mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Workouts from /v2/usercollection/workout land as Workout rows keyed on the Oura id, with the activity mapped onto the canonical sport set and the raw label, intensity and recording source kept in metadata. Heart rate from /v2/usercollection/heartrate lands as PULSE, streamed page by page and written in bounded batches, first write wins per instant. The hourly window for heart rate is hours, not days: six by default, reaching back to the newest reading after a gap, at most 72. Both collections sit behind their own scopes (workout, heartrate). A grant from before they were requested answers 401 naming the scope; that is recorded as a scope note on the card, never as a failure or a reconnect. Collection refusals carry Retry-After on a 429.
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
/**
|
|
* v1.17.0 — off-response classifier for Oura Cloud API v2 replies.
|
|
* HTTP-status-driven; the shared core lives in
|
|
* `src/lib/integrations/http-status-classifier.ts` and is re-exported here
|
|
* under the Oura-specific names callers already use.
|
|
*
|
|
* - `success` → HTTP 2xx
|
|
* - `transient` → 429 (rate-limited), 5xx, 3xx, unknown.
|
|
* - `reauth_required` → 401 / 403 — token rejected / grant revoked.
|
|
* - `persistent` → any other 4xx — malformed request / contract drift.
|
|
*
|
|
* A collection read refines this from the body (`collectionRefusal` in
|
|
* `./client`): a 401 that names a scope is a missing permission, a 403 a lapsed
|
|
* membership.
|
|
*/
|
|
|
|
import {
|
|
classifyHttpStatus,
|
|
classifyIntegrationError,
|
|
IntegrationApiError,
|
|
isOAuthInvalidGrant,
|
|
type ClassifiedHttpResponse,
|
|
type IntegrationClassification,
|
|
} from "@/lib/integrations/http-status-classifier";
|
|
|
|
export function classifyOuraResponse(
|
|
httpStatus: number,
|
|
): ClassifiedHttpResponse {
|
|
return classifyHttpStatus(httpStatus);
|
|
}
|
|
|
|
export class OuraApiError extends IntegrationApiError {
|
|
/**
|
|
* The scope Oura named when it refused a collection read for a missing
|
|
* permission (`401 {"detail":"Token is not authorized access stress
|
|
* scope."}`). Undefined for every other failure, a dead token included.
|
|
*/
|
|
readonly missingScope: string | undefined;
|
|
/**
|
|
* How long Oura asked to be left alone, from the `Retry-After` of a 429 (or
|
|
* 503). Undefined when the refusal carried none or was not a rate limit.
|
|
* A backfill waits at least this long before its next slice.
|
|
*/
|
|
readonly retryAfterMs: number | undefined;
|
|
|
|
constructor(opts: {
|
|
verb: string;
|
|
classification: IntegrationClassification;
|
|
httpStatus: number | undefined;
|
|
reason: string;
|
|
upstreamError?: string;
|
|
missingScope?: string;
|
|
retryAfterMs?: number;
|
|
}) {
|
|
const { missingScope, retryAfterMs, ...rest } = opts;
|
|
super({
|
|
vendor: "Oura",
|
|
...rest,
|
|
upstreamError:
|
|
rest.upstreamError ??
|
|
(missingScope ? `scope_missing:${missingScope}` : undefined),
|
|
});
|
|
this.name = "OuraApiError";
|
|
this.missingScope = missingScope;
|
|
this.retryAfterMs = retryAfterMs;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
|
|
* the canonical revoked-refresh-token signal. Lifts a 400 carrying
|
|
* `invalid_grant` from `persistent` to `reauth_required` so the connection
|
|
* surfaces the reconnect prompt. Internal to `classifyOuraError`.
|
|
*/
|
|
function isInvalidGrant(err: unknown): boolean {
|
|
return isOAuthInvalidGrant(err, "Oura");
|
|
}
|
|
|
|
export function classifyOuraError(err: unknown): IntegrationClassification {
|
|
if (isInvalidGrant(err)) return "reauth_required";
|
|
return classifyIntegrationError(err, "Oura");
|
|
}
|