Return HTTP 409 for business conflicts to prevent PostgREST retry loops
This commit is contained in:
parent
d4e02cab8b
commit
ff09be6267
@ -7,3 +7,15 @@ The shared transport now validates JSON responses and uses the same backend dire
|
||||
Membership requests for the same account share one in-flight operation. Late responses from a different account are ignored. Missing RPC compatibility is used only for a missing function, not network errors. A failed membership request produces a retry/exit screen and cannot be mistaken for a developer account with no company. Successful login still opens the normal application.
|
||||
|
||||
Regression coverage includes empty/invalid proxy responses, identical request bodies and authorization on fallback, non-replayed writes, credential rejection, concurrent membership loads, recovery UI, and a real Supabase SDK login against controlled endpoint responses. The full flow is checked on desktop and mobile without changing production credentials or authentication requirements.
|
||||
# Server retry loop discovered during production verification
|
||||
|
||||
Supabase logs showed PostgREST 14.5 repeatedly executing
|
||||
`sun_v17_save_client_v1772` with `SUN_CLIENT_CONFLICT expected=1 actual=2`,
|
||||
SQLSTATE `40001`, exhausting the REST connection pool. Both direct and proxied
|
||||
workspace reads then timed out. See the [Supabase incident guidance](https://supabase.com/docs/guides/troubleshooting/high-cpu-and-infinite-transaction-retries-when-using-custom-error-codes-in-rpc-functions-77326b).
|
||||
|
||||
Migration `20260918000000_conflict_http_status.sql` changes the two application
|
||||
version-conflict handlers to `PT409`. It preserves function bodies, privileges,
|
||||
revision checks and data. Existing looping backends must be identified in this
|
||||
project's logs and `pg_stat_activity`, then terminated individually. The SQL smoke
|
||||
test verifies both conflicts return `PT409` and stale writes leave data unchanged.
|
||||
|
||||
30
supabase/migrations/20260918000000_conflict_http_status.sql
Normal file
30
supabase/migrations/20260918000000_conflict_http_status.sql
Normal file
@ -0,0 +1,30 @@
|
||||
begin;
|
||||
|
||||
-- Business version conflicts are HTTP 409, not serialization failures.
|
||||
-- PostgREST 14 retries SQLSTATE 40001 indefinitely, exhausting its pool.
|
||||
-- Preserve the current function bodies, ownership, grants and all access checks.
|
||||
do $migration$
|
||||
declare
|
||||
signature text;
|
||||
definition text;
|
||||
fixed text;
|
||||
begin
|
||||
foreach signature in array array[
|
||||
'public.sun_save_app_state_v17(uuid,jsonb,text,bigint)',
|
||||
'public.sun_v17_save_client_v1772(uuid,text,jsonb,bigint,text)'
|
||||
] loop
|
||||
definition := pg_get_functiondef(signature::regprocedure);
|
||||
fixed := replace(definition, 'errcode=''40001''', 'errcode=''PT409''');
|
||||
if fixed = definition then
|
||||
if position('errcode=''PT409''' in definition) = 0 then
|
||||
raise exception 'Unexpected conflict handler in %', signature;
|
||||
end if;
|
||||
else
|
||||
execute fixed;
|
||||
end if;
|
||||
end loop;
|
||||
end;
|
||||
$migration$;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
commit;
|
||||
@ -31,10 +31,14 @@ let row=await one('select * from public.sun_fetch_app_state($1)',[ws]);
|
||||
assert.deepEqual(row.payload.storage.sunBoxes?.v||[],[]);
|
||||
let payload=row.payload;payload.storage.sunOrders={t:'j',v:[{id:1,client:'Test customer',date:'2099-01-01',time:'12:00',total:5000,status:'Новый'}]};payload.storage.sunBoxes={t:'j',v:[{id:'box-1',name:'Test box',price:500}]};
|
||||
row=await one('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'recovery-test',row.revision]);
|
||||
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'stale-client',row.revision-1],/SUN_CONFLICT/);
|
||||
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'stale-client',row.revision-1],e=>e.code==='PT409'&&/SUN_CONFLICT/.test(e.message));
|
||||
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[ws])).revision,row.revision);
|
||||
const snap=(await one('select public.sun_v17_entity_snapshot($1) as s',[ws])).s;
|
||||
assert.equal(snap.orders.length,1);assert.equal(snap.catalog.length,1);
|
||||
const client=await one('select * from public.sun_v17_save_client_v1772($1,$2,$3,null,$4)',[ws,'p:79990000000',{identity:{name:'Test client',phone:'+79990000000'}},'recovery-test']);
|
||||
await denied('select * from public.sun_v17_save_client_v1772($1,$2,$3,$4,$5)',[ws,'p:79990000000',{identity:{name:'Stale overwrite'}},client.version-1,'stale-client'],e=>e.code==='PT409'&&/SUN_CLIENT_CONFLICT/.test(e.message));
|
||||
const unchangedClient=await one('select * from public.sun_v17_clients_snapshot_v1773($1) where client_key=$2',[ws,'p:79990000000']);
|
||||
assert.equal(unchangedClient.version,client.version);assert.equal(unchangedClient.name,'Test client');
|
||||
payload.storage.sunPdfBrandNameV1='Company A';
|
||||
payload.storage.sunPdfBrandLogoV1='data:image/png;base64,dGVzdA==';
|
||||
row=await one('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'brand-owner',row.revision]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user