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:
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user