Skip to content

fix(permissions,console): /me/permissions 的瞬时失败改为重试,不再把 console 钉在 spinner 上 (#3050) - #3052

Merged
os-zhuang merged 1 commit into
mainfrom
claude/vercel-startup-permissions-provider-iwtkqm
Jul 30, 2026
Merged

fix(permissions,console): /me/permissions 的瞬时失败改为重试,不再把 console 钉在 spinner 上 (#3050)#3052
os-zhuang merged 1 commit into
mainfrom
claude/vercel-startup-permissions-provider-iwtkqm

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3050。在实施 objectstack#4159 / cloud#927 时发现并核实 —— 那两个改动让 503 成为这条端点的常规答案,于是这个既有缺口从"偶发"变成"结构性会发生"。

问题

MePermissionsProvider 取失败时只置 error,而渲染分支是:

if (error && !data) {
  if (errorFallback) return <>{errorFallback(error, fetchPermissions)}</>;
  return <>{loadingFallback}</>;      // ← 没传 errorFallback 时,错误态也渲染 loading 节点
}

apps/console/src/AppContent.tsx 恰恰只传 loadingFallback。两条合起来:任何一次取失败 → console 永远停在 LoadingScreen,而那个本该救场的 retry 参数没有任何路径能被调用到。

objectstack#4159 之后,多租户宿主上这条由环境 kernel 应答,冷 kernel 返回 503 + Retry-After(cloud KernelWarmingError,默认 retryAfterSeconds = 15)。所以:租户环境冷启动期间打开 console → 白屏 spinner,且不会自愈。

改法:两处都改,单修任一处都不够

MePermissionsProvider 重试瞬时失败。

状态 处理
408 / 425 请求本身没落地 → 重试
429 限流,通常带 Retry-After → 重试
502 / 503 / 504 上游缺席/预热中/超时 → 重试(503 是这里的承重项
抛出的 fetch(离线/DNS/连接中断) 压根没拿到答案 → 重试
401 / 403 / 404 / 500 关于调用者的真实答案 → 第一次就失败,与改动前一致

500 刻意不重试:真实的服务端故障,猛敲既没用也不诚实。

服务端给的 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 取消令牌处理掉。

关于实现形态(一处自我推翻)

第一版用 mounted ref 做卸载守卫,引入了两条新的 lint 警告(react-hooks/refsreact-refresh/only-export-components)。没有去抑制它们,而是按它们指的方向重构:

  • 重试原语(parseRetryAfterMs / backoffMs / isTransientFailure / TRANSIENT_STATUS / PermissionsFetchError)抽到内部 ./retry 模块 —— 纯函数、纯测试,且组件文件同时导出助手会破坏 fast refresh。不从包根导出
  • ref 换成 per-effect 取消令牌 —— 顺带就修好了上面那个竞态。

lint 回到该文件改动前的基线(0 error,无新增警告)。

验证

范围 结果
pnpm type-check(全仓) 78/78
packages/permissions 107 passed
apps/console 107 passed
新增重试用例 14 passed
改动文件 eslint 0 error,无新增警告

新增 14 例:

  • 从预热 503 恢复,并拿到真实权限答案(不是匿名 fail-open);
  • Retry-After 优先于自己的退避 —— 无需操控计时器即可证明:退避基数设成 100s,若不采用 header 该用例根本跑不完;
  • 抛出的 fetch 同样重试;
  • 重试期间保持 fail-closed loading,children 不渲染(不会闪出宽松默认);
  • 用尽 maxRetries 后暴露错误,且 fetch 次数恰为 1 + maxRetries
  • 401/403/404/500 各自只调用一次 fetch;
  • maxRetries: 0 完全关闭重试;
  • parseRetryAfterMs 覆盖 delta-seconds、HTTP-date、夹取上限,以及所有不可解析/过期输入。

全量 pnpm test(基线 746 files / 8651 tests)正在跑,结果出来我会更新在这里;上面四项已覆盖本 PR 触及的全部包。

关联


🤖 Generated with Claude Code

https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd


Generated by Claude Code

…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
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Jul 30, 2026 2:17pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 27.9 KB 350 KB
Entry file index-D4ii2dce.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.20KB 2.97KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.12KB 3.41KB
auth (LoginForm.js) 17.86KB 5.29KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.43KB 2.09KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 457.97KB 100.09KB
core (index.js) 2.16KB 0.78KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 134.67KB 34.24KB
fields (index.js) 222.07KB 54.35KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 25.17KB 5.80KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.45KB 10.67KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.76KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (retry.js) 3.48KB 1.61KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.90KB 12.35KB
plugin-charts (index.js) 60.52KB 17.11KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 111.59KB 28.74KB
plugin-designer (index.js) 210.51KB 42.50KB
plugin-detail (index.js) 221.42KB 54.12KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 106.54KB 25.88KB
plugin-gantt (index.js) 162.26KB 39.53KB
plugin-grid (index.js) 184.01KB 48.28KB
plugin-kanban (index.js) 47.82KB 13.18KB
plugin-list (index.js) 102.39KB 24.18KB
plugin-map (index.js) 16.80KB 5.24KB
plugin-markdown (index.js) 13.65KB 4.67KB
plugin-report (index.js) 40.32KB 10.53KB
plugin-timeline (index.js) 25.75KB 7.32KB
plugin-tree (index.js) 8.36KB 2.81KB
plugin-view (index.js) 85.95KB 21.02KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 3.47KB 1.54KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (index.js) 2.07KB 0.99KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.04KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 1.08KB 0.64KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 14:23
@os-zhuang
os-zhuang merged commit 2307b52 into main Jul 30, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/vercel-startup-permissions-provider-iwtkqm branch 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants