@@ -4,12 +4,19 @@ defmodule GameServer.Accounts.AvatarMirror do
44 own object storage, so avatars render from our storage/CDN instead of
55 hotlinking the provider.
66
7- Enqueued **once** — the first time a user gets a provider avatar while nothing
8- is stored yet (see `GameServer.Accounts.maybe_mirror_avatar/2`). We never
9- re-mirror: a repeated fetch is wasteful and can trip a provider's rate limits,
10- so if the download fails the provider URL simply stays as the fallback.
7+ Enqueued on sign-in whenever a user's avatar is still an external URL (see
8+ `GameServer.Accounts.maybe_mirror_avatar/1`). Once mirrored the stored URL
9+ lives under our own `avatars/<user_id>/` prefix, so the check short-circuits
10+ and we never re-fetch an avatar we already host.
11+
12+ A *failed* mirror is a different matter from a finished one. Provider CDNs
13+ rate-limit (Google answers 429 readily), so the download is retried with
14+ backoff, and a run that exhausts its attempts does not poison the user
15+ forever — the next sign-in enqueues a fresh job. Until one succeeds the
16+ provider URL stays as the fallback, which is why an un-mirrored avatar can
17+ still 429 in the browser.
1118 """
12- use Oban.Worker , queue: :storage , max_attempts: 1
19+ use Oban.Worker , queue: :storage , max_attempts: 5
1320
1421 require Logger
1522
@@ -21,22 +28,21 @@ defmodule GameServer.Accounts.AvatarMirror do
2128 case Accounts . get_user ( user_id ) do
2229 # Mirror only while the stored avatar is still exactly the provider URL we
2330 # were asked to mirror. If the user has since uploaded or changed it, or is
24- # gone, leave things alone .
31+ # gone, there is nothing to do and nothing to retry .
2532 % { profile_url: ^ source_url } = user -> mirror ( user , source_url )
2633 _ -> :ok
2734 end
2835 rescue
2936 e ->
30- Logger . info ( "avatar mirror crashed user=#{ user_id } : #{ inspect ( e ) } " )
31- :ok
37+ Logger . warning ( "avatar mirror crashed user=#{ user_id } : #{ inspect ( e ) } " )
38+ { :error , e }
3239 end
3340
3441 defp mirror ( user , source_url ) do
3542 # `:avatar_mirror_req_options` lets tests inject a Req.Test plug; empty in prod.
3643 req_opts = Application . get_env ( :game_server_core , :avatar_mirror_req_options , [ ] )
3744
38- with { :ok , % Req.Response { status: 200 } = resp } <-
39- Req . get ( source_url , [ decode_body: false ] ++ req_opts ) ,
45+ with { :ok , % Req.Response { status: 200 } = resp } <- fetch ( source_url , req_opts ) ,
4046 body when is_binary ( body ) <- resp . body ,
4147 content_type <- content_type ( resp , source_url ) ,
4248 :ok <- Storage . validate_upload ( content_type , byte_size ( body ) ) ,
@@ -46,13 +52,29 @@ defmodule GameServer.Accounts.AvatarMirror do
4652 _ = Accounts . prune_user_avatars ( user . id , key )
4753 :ok
4854 else
55+ # Rate limits and provider hiccups are worth another go; Oban backs off.
56+ { :ok , % Req.Response { status: status } } when status in [ 408 , 429 ] or status >= 500 ->
57+ Logger . info ( "avatar mirror retrying user=#{ user . id } : HTTP #{ status } " )
58+ { :error , { :http , status } }
59+
60+ { :error , reason } ->
61+ Logger . info ( "avatar mirror retrying user=#{ user . id } : #{ inspect ( reason ) } " )
62+ { :error , reason }
63+
64+ # A 404, an image type we refuse, a body that is not binary: retrying
65+ # changes nothing, so stop and leave the provider URL as the fallback.
4966 other ->
50- # Keep the provider URL as the fallback; do not retry.
51- Logger . info ( "avatar mirror skipped user=#{ user . id } : #{ inspect ( other ) } " )
52- :ok
67+ Logger . info ( "avatar mirror gave up user=#{ user . id } : #{ inspect ( other ) } " )
68+ { :cancel , other }
5369 end
5470 end
5571
72+ defp fetch ( source_url , req_opts ) do
73+ Req . get ( source_url , [ decode_body: false ] ++ req_opts )
74+ rescue
75+ e -> { :error , e }
76+ end
77+
5678 defp content_type ( resp , url ) do
5779 case Req.Response . get_header ( resp , "content-type" ) do
5880 [ ct | _ ] when is_binary ( ct ) -> ct |> String . split ( ";" ) |> List . first ( ) |> String . trim ( )
0 commit comments