Return HTTP 409 for business conflicts to prevent PostgREST retry loops

This commit is contained in:
pavlov346346-source 2026-09-18 02:52:44 +03:00
parent d4e02cab8b
commit ff09be6267
3 changed files with 47 additions and 1 deletions

View File

@ -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. 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. 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.

View 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;

View File

@ -31,10 +31,14 @@ let row=await one('select * from public.sun_fetch_app_state($1)',[ws]);
assert.deepEqual(row.payload.storage.sunBoxes?.v||[],[]); 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}]}; 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]); 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; 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); 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']); 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.sunPdfBrandNameV1='Company A';
payload.storage.sunPdfBrandLogoV1='data:image/png;base64,dGVzdA=='; 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]); row=await one('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'brand-owner',row.revision]);