fix(permissions,console): /me/permissions 的瞬时失败改为重试,不再把 console 钉在 spinner 上 (#3050) - #3052
Merged
os-zhuang merged 1 commit intoJul 30, 2026
Merged
Conversation
…nstead of stranding the app on its loading state (#3050) "Not now" is a real answer from this endpoint. On a multi-tenant host it is served by the environment kernel that owns the session, and a COLD one answers `503` + `Retry-After` while it warms (objectstack#4159 / cloud#927 — `KernelWarmingError` states ~15s). The provider treated that like any other failure: it set `error`. And the render branch falls back to `loadingFallback` for the error state whenever no `errorFallback` is given — which is exactly how the console wires it. So a tenant opening the console during a cold start sat on a spinner indefinitely, with a `retry` no path could reach. Both halves are needed; neither alone is enough. 1. The fetch re-attempts a TRANSIENT failure — `408`, `425`, `429`, `502`, `503`, `504`, or a thrown fetch (offline / DNS / aborted), which never got an answer at all. A server-stated `Retry-After` wins over the exponential backoff (both wire forms parsed, clamped to 30s so a hostile value cannot park the UI); otherwise the delay doubles from `retryBaseDelayMs`. `loading` stays true across the waits, so the fail-closed state holds and consumers never see a permissive flash mid-recovery. `401`/`403`/`404`/`500` are real answers about the caller and fail on the first attempt, exactly as before — `500` deliberately included: hammering a genuine fault neither helps nor is honest about it. New props, both defaulted so no call site changes: `maxRetries` (3; `0` restores the old single-attempt behaviour) and `retryBaseDelayMs` (500). 2. The console passes an `errorFallback`. Retrying narrows the window but cannot close it — a build slower than the retry budget still lands in the error state. It now renders `<LoadingScreen error onRetry />`, the affordance that component has carried all along and nobody wired. Also fixes a latent race the retries made much wider: the in-flight fetch is cancelled when the effect tears down, so a slow answer for a previous `endpoint` or `fetcher` can no longer overwrite a fast answer for the current one. The retry primitives live in an internal `./retry` module — plain functions with plain tests, and a component file that also exports helpers breaks fast refresh. That shape came from following two lint warnings the first attempt introduced (`react-hooks/refs` from a mounted-ref guard, `react-refresh/only-export-components`) rather than suppressing them; the cancellation token replaced the ref and fixed the race above as a side effect. Lint is back to the file's prior baseline. Tests: 14 new — recovery from a warming 503; `Retry-After` preferred over the backoff (proved without timer control: a 100s base that must be overridden for the test to finish); a thrown fetch retried; the loading state held with no permissive flash; give-up after `maxRetries` with the error surfaced; no retry on 401/403/404/500; `maxRetries: 0`; and `parseRetryAfterMs` across both wire forms, clamping, and every unparseable/past input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
os-zhuang
marked this pull request as ready for review
July 30, 2026 14:23
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…ization failure (#3098) `/auth/me/localization` is served by the environment kernel that owns the session on a multi-tenant host, and a cold one answers 503 + `Retry-After` while it warms (objectstack#4159). A transient failure is a normal part of a cold start there, not an exception. The provider made ONE attempt and `.catch()`-ed into silence, so a single 503 during warm-up left currency and locale unset for the WHOLE session — every money field a plain number, nothing ever trying again, long after the kernel was ready. Silent and permanent, which is why nobody would report it as a bug. I had looked at this file during #3052 and judged it "cosmetic, degrades gracefully, no change needed". Right at the time and wrong afterwards: objectstack#4159 turned 503 from an anomaly into this endpoint's routine answer, and the judgement was never revisited. The asymmetry with MePermissionsProvider — same host, same provenance, same 503, opposite handling — is what surfaced it. PRIMITIVES SHARED, POLICY NOT. "Is this transient", "how long to wait" and the `Retry-After` parsing move from @object-ui/permissions' internal module to @object-ui/types — the lowest package both callers reach — and `PermissionsFetchError` becomes the generic `HttpFetchError`. The bundle report confirms it is a move, not a copy: `permissions (retry.js)` left the listing as `types (http-retry.js)` entered it. No behaviour change for MePermissionsProvider. The policies stay separate because the postures are opposite: permissions is FAIL-CLOSED and holds its loading state across the waits (rendering a permissive default early is a real hazard); localization is COSMETIC and keeps rendering children throughout, filling the value in if and when an attempt succeeds. Its budget is also more patient (4 × 1s base vs 3 × 500ms) — nothing waits on it, so it can still be trying when a slow environment finishes warming. 401/403/404/500 remain first-attempt failures on both. Tests: 8 new, where there were none. One pins the POSTURE rather than the behaviour — children must be rendered while the fetch is still failing — because the plausible regression here is someone "aligning the two providers" and carrying the fail-closed blocking onto a surface that must never block. Sharing the primitives makes that impulse more likely, not less. Verified: console localization 8/8, permissions 124/124 (regression), full objectui suite 8998 passed / 24 skipped, type-check 78/78, CI 15/15.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3050。在实施 objectstack#4159 / cloud#927 时发现并核实 —— 那两个改动让
503成为这条端点的常规答案,于是这个既有缺口从"偶发"变成"结构性会发生"。问题
MePermissionsProvider取失败时只置error,而渲染分支是:apps/console/src/AppContent.tsx恰恰只传loadingFallback。两条合起来:任何一次取失败 → console 永远停在 LoadingScreen,而那个本该救场的retry参数没有任何路径能被调用到。objectstack#4159 之后,多租户宿主上这条由环境 kernel 应答,冷 kernel 返回
503+Retry-After(cloudKernelWarmingError,默认retryAfterSeconds = 15)。所以:租户环境冷启动期间打开 console → 白屏 spinner,且不会自愈。改法:两处都改,单修任一处都不够
①
MePermissionsProvider重试瞬时失败。408/425429Retry-After→ 重试502/503/504503是这里的承重项)401/403/404/500500刻意不重试:真实的服务端故障,猛敲既没用也不诚实。服务端给的
Retry-After优先于指数退避(两种 wire 形式都读,并夹到 30s 上限 —— 否则一个恶意或错误的值能把 UI 钉住几小时)。重试期间loading保持true,所以 fail-closed 状态不会闪出宽松默认,恢复过程对消费者完全不可见。新增两个 prop,都有默认值,没有任何调用点需要改:
maxRetries(默认3,传0恢复旧的单次行为)、retryBaseDelayMs(默认500)。② console 传
errorFallback。 重试只能收窄窗口、不能关闭它:kernel 构建比重试预算还慢时仍会落到错误态,而在那里渲染loadingFallback正是永久 spinner 的成因。现在渲染<LoadingScreen error={...} onRetry={retry} />——LoadingScreen本来就有error/onRetry/retrying三个 props,一直没人接。顺带修掉的既有竞态
fetchPermissions没有取消机制:endpoint/fetcher变化时,旧请求的慢响应会覆盖新请求的快响应。加了重试(中间隔着真实等待)后这个窗口显著变宽,改用 per-effect 取消令牌处理掉。关于实现形态(一处自我推翻)
第一版用
mountedref 做卸载守卫,引入了两条新的 lint 警告(react-hooks/refs、react-refresh/only-export-components)。没有去抑制它们,而是按它们指的方向重构:parseRetryAfterMs/backoffMs/isTransientFailure/TRANSIENT_STATUS/PermissionsFetchError)抽到内部./retry模块 —— 纯函数、纯测试,且组件文件同时导出助手会破坏 fast refresh。不从包根导出;lint 回到该文件改动前的基线(0 error,无新增警告)。
验证
pnpm type-check(全仓)packages/permissionsapps/console新增 14 例:
503恢复,并拿到真实权限答案(不是匿名 fail-open);Retry-After优先于自己的退避 —— 无需操控计时器即可证明:退避基数设成 100s,若不采用 header 该用例根本跑不完;maxRetries后暴露错误,且 fetch 次数恰为1 + maxRetries;401/403/404/500各自只调用一次 fetch;maxRetries: 0完全关闭重试;parseRetryAfterMs覆盖 delta-seconds、HTTP-date、夹取上限,以及所有不可解析/过期输入。关联
503成为常规答案)、cloud#924 / cloud#928。🤖 Generated with Claude Code
https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd
Generated by Claude Code