Offline: Fotos bei abgelaufener Testphase nicht mehr verwerfen

Upload-Antwort 422 trial_expired gilt als vorübergehend: Datei bleibt auf dem Gerät,
wartende Op bleibt in der Warteschlange, der Sync-Durchlauf stoppt mit Backoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:23:14 +02:00
co-authored by Claude Opus 5
parent a8fedc390f
commit dd6a67d329
3 changed files with 31 additions and 6 deletions
+11 -1
View File
@@ -31,7 +31,7 @@ export type OfflineState = {
summary: OutboxSummary;
lastSyncAt: string | null;
lastPullAt: string | null;
lastError: null | "network" | "unauthorized" | "internal";
lastError: null | "network" | "unauthorized" | "suspended" | "internal";
storage: StorageInfo;
/** upload progress per blob client id (0–100) */
uploads: Record<string, number>;
@@ -88,6 +88,14 @@ async function requireRuntime(): Promise<{ store: OfflineStore; ctx: OfflineCont
const uploadProgress = new Map<string, (percent: number) => void>();
function isTrialExpired(body: string): boolean {
try {
return (JSON.parse(body) as { error?: { message?: string } }).error?.message === "trial_expired";
} catch {
return false;
}
}
const browserTransport: Transport = {
async sendBatch(device, operations) {
let res: Response;
@@ -141,6 +149,8 @@ const browserTransport: Transport = {
return;
}
if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" });
// expired trial (read-only tenant) → transient: keep the file on the device until extended/converted
if (xhr.status === 422 && isTrialExpired(xhr.responseText)) return resolve({ ok: false, error: "suspended" });
// 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff
if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" });
if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" });
+8 -4
View File
@@ -20,7 +20,8 @@ import {
*/
export type BatchOutcome = { ok: true; response: SyncResponse } | { ok: false; error: "network" | "unauthorized" | "internal" };
export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "invalid" | "forbidden" | "not_found" | "internal" };
/** `suspended` = tenant temporarily read-only (expired trial) → transient, the file stays on the device. */
export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "suspended" | "invalid" | "forbidden" | "not_found" | "internal" };
export type Transport = {
sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise<BatchOutcome>;
@@ -48,7 +49,7 @@ export type PassResult = {
uploaded: number;
uploadFailed: number;
/** transport-level stop reason of the pass */
stopped: null | "network" | "unauthorized" | "internal";
stopped: null | "network" | "unauthorized" | "suspended" | "internal";
/** client id → server id of this pass */
idMap: Record<string, string>;
};
@@ -123,7 +124,10 @@ export async function runSyncPass(deps: EngineDeps): Promise<PassResult> {
continue;
}
const attempts = blob.attempts + 1;
const error: OutboxError = { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error };
const error: OutboxError =
up.error === "suspended"
? { code: "blocked", message: "trial_expired" }
: { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error };
if (FINAL_UPLOAD_ERRORS.has(up.error)) {
await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso });
res.uploadFailed++;
@@ -134,7 +138,7 @@ export async function runSyncPass(deps: EngineDeps): Promise<PassResult> {
}
} else {
await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: new Date(now().getTime() + backoffMs(attempts, random)).toISOString(), updatedAt: iso });
if (up.error === "network" || up.error === "unauthorized") {
if (up.error === "network" || up.error === "unauthorized" || up.error === "suspended") {
res.stopped = up.error;
return res;
}