diff --git a/backend/internal/api/readingroom.go b/backend/internal/api/readingroom.go index 0db4950..bd3c71f 100644 --- a/backend/internal/api/readingroom.go +++ b/backend/internal/api/readingroom.go @@ -14,6 +14,7 @@ import ( "margin.at/internal/cloudflare" "margin.at/internal/config" "margin.at/internal/db" + "margin.at/internal/logger" "margin.at/internal/xrpc" ) @@ -808,7 +809,12 @@ func (h *Handler) GetBillingStatus(w http.ResponseWriter, r *http.Request) { return } - sub, _ := h.db.GetSubscription(r.Context(), session.DID) + sub, err := h.db.GetSubscription(r.Context(), session.DID) + if err != nil { + logger.Error("GetBillingStatus: failed to read subscription for %s: %v", session.DID, err) + WriteJSONError(w, http.StatusServiceUnavailable, "billing temporarily unavailable") + return + } resp := BillingStatusResponse{} if sub != nil { resp.Status = sub.Status @@ -817,6 +823,7 @@ func (h *Handler) GetBillingStatus(w http.ResponseWriter, r *http.Request) { resp.HasSubscription = sub.Status == "active" || sub.Status == "trialing" } + w.Header().Set("Cache-Control", "no-store") WriteSuccess(w, resp) } diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index e5d3c0c..3b31f52 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "margin.at/internal/db/sqlcdb" @@ -62,6 +63,8 @@ func New(dsn string) (*DB, error) { cfg.MaxConnIdleTime = 15 * time.Minute cfg.HealthCheckPeriod = 30 * time.Second + cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeCacheDescribe + pool, err := pgxpool.NewWithConfig(context.Background(), cfg) if err != nil { return nil, fmt.Errorf("open database: %w", err) @@ -108,37 +111,47 @@ func (db *DB) Close() error { } func (db *DB) AdvisoryLock(ctx context.Context, key int64, fn func() error) (bool, error) { - conn, err := db.pool.Acquire(ctx) + tx, err := db.pool.Begin(ctx) if err != nil { - return false, fmt.Errorf("advisory lock: acquire conn: %w", err) + return false, fmt.Errorf("advisory lock: begin tx: %w", err) } - defer conn.Release() + defer tx.Rollback(ctx) //nolint:errcheck - if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", key); err != nil { + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", key); err != nil { return false, fmt.Errorf("advisory lock: acquire: %w", err) } - defer conn.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", key) //nolint:errcheck - return true, fn() + if err := fn(); err != nil { + return true, err + } + if err := tx.Commit(ctx); err != nil { + return true, fmt.Errorf("advisory lock: commit: %w", err) + } + return true, nil } func (db *DB) TryAdvisoryLock(ctx context.Context, key int64, fn func() error) (bool, error) { - conn, err := db.pool.Acquire(ctx) + tx, err := db.pool.Begin(ctx) if err != nil { - return false, fmt.Errorf("try advisory lock: acquire conn: %w", err) + return false, fmt.Errorf("try advisory lock: begin tx: %w", err) } - defer conn.Release() + defer tx.Rollback(ctx) //nolint:errcheck var acquired bool - if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&acquired); err != nil { + if err := tx.QueryRow(ctx, "SELECT pg_try_advisory_xact_lock($1)", key).Scan(&acquired); err != nil { return false, fmt.Errorf("try advisory lock: %w", err) } if !acquired { return false, nil } - defer conn.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", key) //nolint:errcheck - return true, fn() + if err := fn(); err != nil { + return true, err + } + if err := tx.Commit(ctx); err != nil { + return true, fmt.Errorf("try advisory lock: commit: %w", err) + } + return true, nil } func ParseSelector(selectorJSON *string) (*Selector, error) { diff --git a/backend/internal/db/queries_oauth.go b/backend/internal/db/queries_oauth.go index 81a6e70..1dff502 100644 --- a/backend/internal/db/queries_oauth.go +++ b/backend/internal/db/queries_oauth.go @@ -251,14 +251,18 @@ func hashLockKey(name string) int64 { func (db *DB) WithAdvisoryLock(ctx context.Context, name string, fn func(context.Context) error) error { key := hashLockKey(name) - conn, err := db.pool.Acquire(ctx) + tx, err := db.pool.Begin(ctx) if err != nil { - return fmt.Errorf("advisory lock: acquire conn: %w", err) + return fmt.Errorf("advisory lock: begin tx: %w", err) } - defer conn.Release() - if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", key); err != nil { + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", key); err != nil { return fmt.Errorf("advisory lock: %w", err) } - defer conn.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", key) //nolint:errcheck - return fn(ctx) + + if err := fn(ctx); err != nil { + return err + } + return tx.Commit(ctx) } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ad5d6c4..55acc53 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1713,12 +1713,24 @@ export async function createPortal(): Promise { } export async function getBillingStatus(): Promise { - try { - const res = await apiRequest("/api/billing/status"); - if (!res.ok) return null; - return await res.json(); - } catch (e) { - console.error("Failed to fetch billing status:", e); - return null; + const MAX_ATTEMPTS = 3; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const res = await apiRequest("/api/billing/status"); + if (res.status >= 500 && attempt < MAX_ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); + continue; + } + if (!res.ok) return null; + return await res.json(); + } catch (e) { + if (attempt < MAX_ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); + continue; + } + console.error("Failed to fetch billing status:", e); + return null; + } } + return null; } diff --git a/web/src/components/navigation/RightSidebar.tsx b/web/src/components/navigation/RightSidebar.tsx index ba8fa4f..2177920 100644 --- a/web/src/components/navigation/RightSidebar.tsx +++ b/web/src/components/navigation/RightSidebar.tsx @@ -152,7 +152,7 @@ export default function RightSidebar({ onNavigate }: RightSidebarProps) { getBillingStatus().then((b) => setBilling(b)); }, [user]); - const showProCard = !billing?.hasSubscription; + const showProCard = billing !== null && !billing.hasSubscription; const extensionLink = browser === "firefox" diff --git a/web/src/views/Pro.tsx b/web/src/views/Pro.tsx index 4df03a7..89a5950 100644 --- a/web/src/views/Pro.tsx +++ b/web/src/views/Pro.tsx @@ -41,6 +41,7 @@ export default function Pro() { }, []); const hasSub = billing?.hasSubscription ?? false; + const billingUnknown = !!user && billing === null; const handleCheckout = async (plan: Plan) => { if (!user) { @@ -154,7 +155,7 @@ export default function Pro() {

- {!ready ? ( + {!ready || billingUnknown ? ( ) : hasSub ? ( <> diff --git a/web/src/views/core/ReadingRoomSettings.tsx b/web/src/views/core/ReadingRoomSettings.tsx index 84372ec..6e53a76 100644 --- a/web/src/views/core/ReadingRoomSettings.tsx +++ b/web/src/views/core/ReadingRoomSettings.tsx @@ -171,7 +171,7 @@ export default function ReadingRoomSettings() {
); - if (loading) { + if (loading || billing === null) { return (
{header} diff --git a/web/src/views/core/Settings.tsx b/web/src/views/core/Settings.tsx index 2b01418..94f187d 100644 --- a/web/src/views/core/Settings.tsx +++ b/web/src/views/core/Settings.tsx @@ -769,7 +769,15 @@ export default function Settings() { {t("settings.sections.billing")} - {billing?.hasSubscription ? ( + {billing === null ? ( +
+
+
+
+
+
+
+ ) : billing.hasSubscription ? (