Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/app/(profile)/profile/[destinyMembershipId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "~/lib/profile/prefetch"
import { type ProfileProps } from "~/lib/profile/types"
import { bungieProfileIconUrl } from "~/util/destiny"
import { isValidDestinyMembershipId } from "~/util/destiny/routeParams"

export const revalidate = 0

Expand All @@ -19,7 +20,27 @@ type PageProps = {
}
}

function isBenignLinkedProfilesError(err: unknown): boolean {
if (err instanceof DOMException && err.name === "AbortError") {
return true
}

if (err instanceof Error) {
return (
err.name === "AbortError" ||
err.message.includes("aborted") ||
err.message.includes("Operation failed after")
)
}

return false
}

export default async function Page({ params }: PageProps) {
if (!isValidDestinyMembershipId(params.destinyMembershipId)) {
notFound()
}

// Find the app profile by id if it exists
const [appProfile, basicProfile] = await Promise.all([
getUniqueProfileByDestinyMembershipId(params.destinyMembershipId),
Expand All @@ -32,7 +53,13 @@ export default async function Page({ params }: PageProps) {

const linkedProfilesResponse = await prefetchDestinyLinkedProfiles(
params.destinyMembershipId
)
).catch(err => {
if (isBenignLinkedProfilesError(err)) {
return null
}

throw err
})
const applicableMemberships = linkedProfilesResponse?.profiles.filter(
m => m.applicableMembershipTypes.length > 0
)
Expand Down Expand Up @@ -85,6 +112,15 @@ export default async function Page({ params }: PageProps) {
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
if (!isValidDestinyMembershipId(params.destinyMembershipId)) {
return {
robots: {
follow: true,
index: false
}
}
}

const [profile, basic] = await Promise.all([
getUniqueProfileByDestinyMembershipId(params.destinyMembershipId),
prefetchRaidHubPlayerBasic(params.destinyMembershipId)
Expand Down
14 changes: 14 additions & 0 deletions src/app/clan/[groupId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { type Metadata } from "next"
import { notFound } from "next/navigation"
import { ClanComponent } from "~/components/__deprecated__/clan/Clan"
import { PageWrapper } from "~/components/PageWrapper"
import { baseMetadata } from "~/lib/metadata"
import { fixClanName } from "~/util/destiny/fixClanName"
import { isValidClanGroupId } from "~/util/destiny/routeParams"
import { getClan, type PageProps } from "../server"

export const revalidate = 0

export default async function Page({ params }: PageProps) {
if (!isValidClanGroupId(params.groupId)) {
notFound()
}

const clan = await getClan(params.groupId)
if (!clan) {
notFound()
}

return (
<PageWrapper>
<ClanComponent clan={clan} groupId={params.groupId} />
Expand All @@ -17,6 +27,10 @@ export default async function Page({ params }: PageProps) {
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
if (!isValidClanGroupId(params.groupId)) {
return {}
}

const clan = await getClan(params.groupId)

if (!clan) return {}
Expand Down
2 changes: 1 addition & 1 deletion src/app/clan/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const clanClient = new ServerBungieClient({
timeout: 6000
})

const expectedErrorCodes = [1, 621, 622, 686]
const expectedErrorCodes = [1, 7, 621, 622, 686]

export const getClan = reactRequestDedupe(async (groupId: string) =>
getGroup(clanClient, { groupId })
Expand Down
6 changes: 6 additions & 0 deletions src/components/__deprecated__/profile/raids/raids.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@
cursor: pointer;
}

.dot > circle,
.dot > polygon,
.dot > svg {
pointer-events: none;
}

.dot:hover circle {
r: 8;
}
Expand Down
13 changes: 11 additions & 2 deletions src/components/profile/raids/finder/InstanceFinder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { OptionalWrapper } from "~/components/OptionalWrapper"
import { usePageProps } from "~/components/PageWrapper"
import { useSession } from "~/hooks/app/useSession"
import { type ProfileProps } from "~/lib/profile/types"
import { useInstances } from "~/services/raidhub/useRaidHubInstances"
import { RaidHubError } from "~/services/raidhub/RaidHubError"
import { RETRIABLE_RAIDHUB_ERROR_CODES, useInstances } from "~/services/raidhub/useRaidHubInstances"
import { InstanceFinderForm } from "./InstanceFinderForm"
import { InstanceTable } from "./InstanceTable"

Expand Down Expand Up @@ -88,7 +89,15 @@ const InstanceFinderInternal = memo(() => {
<div className="overflow-x-auto">
{state.isIdle && <p>Enter your search criteria above to find instances.</p>}
{state.isLoading && <p>Loading...</p>}
{state.isError && <p>Error: {(state.error as Error).message}</p>}
{state.isError && (
<p>
Error:{" "}
{state.error instanceof RaidHubError &&
RETRIABLE_RAIDHUB_ERROR_CODES.has(state.error.errorCode)
? "RaidHub is temporarily unavailable. Please try again in a moment."
: (state.error as Error).message}
</p>
)}
{state.isSuccess && state.data.length === 0 && <p>No instances found.</p>}
{state.isSuccess && state.data.length > 0 && (
<InstanceTable instances={state.data} />
Expand Down
16 changes: 14 additions & 2 deletions src/components/providers/LocaleManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { type DestinyManifestLanguage } from "bungie-net-core/manifest"
import { userAgentFromString } from "next/server"
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"

function isSupportedLocaleTag(locale: string): boolean {
const trimmed = locale.trim()
return trimmed.length > 0 && /^[a-z]{2,3}(-[a-z0-9]+)*$/i.test(trimmed)
}

function sanitizeNavigatorLocales(locales: readonly string[]): string[] {
return locales.filter(isSupportedLocaleTag)
}

const d2ManifestLocales = [
"en",
"fr",
Expand Down Expand Up @@ -37,9 +46,12 @@ export function LocaleManager(props: { children: ReactNode } | null) {
const [manifestLanguage, setManifestLanguage] = useState<DestinyManifestLanguage>("en")

useEffect(() => {
setLocale(navigator.language)
const sanitizedLanguages = sanitizeNavigatorLocales(navigator.languages)
const preferredLocale = sanitizeNavigatorLocales([navigator.language])[0] ?? "en-US"

setLocale(preferredLocale)
const matchedLanguage = match(
navigator.languages,
sanitizedLanguages.length > 0 ? sanitizedLanguages : [preferredLocale],
d2ManifestLocales.map(locale => {
const transformedLocale = locale
.replace(/-chs$/i, "-Hans")
Expand Down
3 changes: 3 additions & 0 deletions src/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Sentry from "@sentry/nextjs"
import { installBrowserCompatShims } from "./lib/browser-compat"
import { beforeSendClientEvent } from "./lib/sentry/client"
import {
getSentryDsnForClient,
Expand All @@ -8,6 +9,8 @@ import {
} from "./lib/sentry/env"
import { sentrySharedOptions } from "./lib/sentry/shared-options"

installBrowserCompatShims()

const dsn = getSentryDsnForClient()

if (dsn) {
Expand Down
51 changes: 51 additions & 0 deletions src/lib/browser-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Early client shims for third-party scripts and embedded browsers that assume
* APIs our app does not provide. Loaded from instrumentation-client before React.
*/
export function installBrowserCompatShims(): void {
if (typeof window === "undefined") {
return
}

// Tampermonkey "open in new tab" scripts set SVGAElement.target (read-only in modern browsers).
try {
const proto = window.SVGAElement?.prototype
if (proto) {
const descriptor = Object.getOwnPropertyDescriptor(proto, "target")
if (descriptor && !descriptor.set) {
Object.defineProperty(proto, "target", {
...descriptor,
set() {
return
},
configurable: true
})
}
}
} catch {
// Read-only prototype in some environments — safe to continue.
}

// In-app browsers inject sendDataToNative expecting window.webkit.messageHandlers.
try {
if (!("webkit" in window)) {
const messageHandlers = new Proxy(
{},
{
get: () => ({
postMessage: () => {
return
}
})
}
)

Object.defineProperty(window, "webkit", {
value: { messageHandlers },
configurable: true
})
}
} catch {
// Cannot define webkit — continue without shim.
}
}
12 changes: 10 additions & 2 deletions src/lib/profile/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,16 @@ export const prefetchDestinyLinkedProfiles = reactRequestDedupe((membershipId: s
.catch(e => {
if (e instanceof BungiePlatformError) {
return null
} else {
throw e
}

if (e instanceof DOMException && e.name === "AbortError") {
return null
}

if (e instanceof Error && (e.name === "AbortError" || e.message.includes("aborted"))) {
return null
}

throw e
})
)
14 changes: 13 additions & 1 deletion src/lib/sentry/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ const HANDLED_RAIDHUB_ERROR_CODES = new Set<RaidHubErrorCode>([
"PathValidationError",
"QueryValidationError",
"BodyValidationError",
"BungieServiceOffline"
"BungieServiceOffline",
"InternalServerError",
"ServiceUnavailableError"
])

const HANDLED_TRPC_ERROR_CODES = new Set(["NOT_FOUND", "UNAUTHORIZED", "FORBIDDEN", "BAD_REQUEST"])
Expand Down Expand Up @@ -191,6 +193,12 @@ function isTransientTrpcHtmlError(error: unknown): boolean {
return message.includes("Unexpected token '<'") || message.includes("<!DOCTYPE")
}

function isTransientBungieHtmlError(error: unknown): boolean {
return (
error instanceof BungieHTMLError && BaseBungieClient.TransientHttpStatuses.has(error.status)
)
}

/** Turso/libSQL blips — retried via saferFetch; still skip if all attempts fail. */
function isTransientTursoPrismaError(error: unknown): boolean {
const message = getErrorMessage(error)
Expand Down Expand Up @@ -288,6 +296,10 @@ export function shouldSkipCapture(error: unknown): boolean {
return true
}

if (isTransientBungieHtmlError(error)) {
return true
}

if (isTransientTrpcHtmlError(error)) {
return true
}
Expand Down
6 changes: 6 additions & 0 deletions src/services/bungie/BungieClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,24 @@ export default abstract class BaseBungieClient implements BungieClientProtocol {
static readonly RetryableErrorCodes = new Set<PlatformErrorCodes>([
1672, // DestinyThrottledByGameServer,
1618, // DestinyUnexpectedError — Bungie 500 blips, often succeeds on retry
1626, // DestinyInternalError — transient Bungie 500 on clan/profile lookups
1688 // DestinyDirectBabelClientTimeout
])

/** Cloudflare / Bungie HTML error pages — retry once before surfacing. */
static readonly TransientHttpStatuses = new Set([502, 503, 504, 520, 522, 524])

static readonly ExpectedErrorCodes = new Set<PlatformErrorCodes>([
5, // SystemDisabled
7, // ParameterParseFailure — invalid clan/group id in URL
8, // ParameterInvalidRange — bad membership/type combos on clan lookups
18, // InvalidParameters — bad membership/type combos on optional lookups (e.g. clans)
217, // UserCannotResolveCentralAccount — player search miss
686, // ClanNotFound
1600, // DestinyAccountAcquisitionFailure — no linked Destiny account
1601, // DestinyAccountNotFound — deleted/wrong-platform membership on profile
1618, // DestinyUnexpectedError — Bungie-side 500, surfaced in UI as load failure
1626, // DestinyInternalError — Bungie-side blip on optional clan lookups
1653, // PGCRNotFound
1665, // DestinyPrivacyRestriction — private profile
1688 // DestinyDirectBabelClientTimeout
Expand Down
12 changes: 10 additions & 2 deletions src/services/bungie/ClientBungieClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BungieFetchConfig } from "bungie-net-core"
import EventEmitter from "events"
import { withBungieAuthFailure } from "~/lib/sentry/context"
import { BungieHTTPError, BungiePlatformError } from "~/models/BungieAPIError"
import { BungieHTMLError, BungieHTTPError, BungiePlatformError } from "~/models/BungieAPIError"
import BaseBungieClient from "./BungieClient"

export default class ClientBungieClient extends BaseBungieClient {
Expand Down Expand Up @@ -96,10 +96,18 @@ export default class ClientBungieClient extends BaseBungieClient {
})
} else if (
err instanceof BungiePlatformError &&
ClientBungieClient.RetryableErrorCodes.has(err.ErrorCode)
ClientBungieClient.RetryableErrorCodes.has(err.ErrorCode) &&
!url.searchParams.has("retry")
) {
url.searchParams.set("retry", err.cause.ErrorStatus)
return this.request(url, payload)
} else if (
err instanceof BungieHTMLError &&
ClientBungieClient.TransientHttpStatuses.has(err.status) &&
!url.searchParams.has("retry")
) {
url.searchParams.set("retry", `html-${err.status}`)
return this.request(url, payload)
} else {
throw err
}
Expand Down
14 changes: 12 additions & 2 deletions src/services/bungie/ServerBungieClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import "server-only"
import type { BungieFetchConfig } from "bungie-net-core"
import { saferFetch } from "~/lib/server/saferFetch"
import { baseUrl } from "~/lib/server/utils"
import { BungiePlatformError } from "~/models/BungieAPIError"
import { BungieHTMLError, BungiePlatformError } from "~/models/BungieAPIError"
import BaseBungieClient from "~/services/bungie/BungieClient"

export default class ServerBungieClient extends BaseBungieClient {
Expand Down Expand Up @@ -67,11 +67,21 @@ export default class ServerBungieClient extends BaseBungieClient {
console.error(err)
if (
err instanceof BungiePlatformError &&
ServerBungieClient.RetryableErrorCodes.has(err.ErrorCode)
ServerBungieClient.RetryableErrorCodes.has(err.ErrorCode) &&
!url.searchParams.has("retry")
) {
url.searchParams.set("retry", err.cause.ErrorStatus)
Comment on lines 67 to 73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The retry logic for error code 1626 in ServerBungieClient is unreachable because the error is also listed in ExpectedErrorCodes, causing the check to fail.
Severity: MEDIUM

Suggested Fix

Refactor the conditional logic in ServerBungieClient.handle(). The check for ExpectedErrorCodes should not prevent the retry logic from being evaluated. Consider moving the retry blocks outside of the ExpectedErrorCodes guard so that retryable errors are handled before being dismissed as expected.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/services/bungie/ServerBungieClient.ts#L67-L73

Potential issue: In `ServerBungieClient.ts`, the error handling logic for retries is
nested inside a condition that explicitly skips errors found in `ExpectedErrorCodes`.
The new code adds error code 1626 to both `RetryableErrorCodes` and
`ExpectedErrorCodes`. As a result, when a `BungiePlatformError` with code 1626 occurs,
the outer condition `!(err instanceof BungiePlatformError &&
ServerBungieClient.ExpectedErrorCodes.has(err.ErrorCode))` evaluates to false. This
prevents the retry logic from ever being executed for this error on the server, and the
error is immediately re-thrown. This defeats the purpose of making the error retryable
and creates an inconsistency where the client-side `ClientBungieClient` would retry, but
the server-side would not.

Also affects:

  • src/services/bungie/BungieClient.ts:83~106

Did we get this right? 👍 / 👎 to inform future reviews.

return this.request(url, payload) as T
}

if (
err instanceof BungieHTMLError &&
ServerBungieClient.TransientHttpStatuses.has(err.status) &&
!url.searchParams.has("retry")
) {
url.searchParams.set("retry", `html-${err.status}`)
return this.request(url, payload) as T
}
}
throw err
}
Expand Down
Loading
Loading