@@ -217,6 +217,94 @@ func TestMetadataFetcher_FetchMetadata(t *testing.T) {
217217 assert .Empty (t , out )
218218 })
219219
220+ t .Run ("skips a contract that is still inside its failure-backoff window" , func (t * testing.T ) {
221+ rpc := services .NewContractMetadataServiceMock (t )
222+ // One failing name() simulation is all the RPC is allowed to see: the
223+ // backoff entry recorded by the first call must suppress the second.
224+ rpc .On ("FetchSingleField" , mock .Anything , testContractA , "name" , mock .Anything ).
225+ Return (xdr.ScVal {}, errors .New ("simulate boom" )).Once ()
226+
227+ f := newMetadataFetcher (rpc , pond .NewPool (2 ))
228+ out , err := f .FetchMetadata (ctx , []string {testContractA })
229+ require .NoError (t , err )
230+ assert .Empty (t , out )
231+
232+ out , err = f .FetchMetadata (ctx , []string {testContractA })
233+ require .NoError (t , err )
234+ assert .Empty (t , out )
235+ rpc .AssertNumberOfCalls (t , "FetchSingleField" , 1 )
236+ })
237+
238+ t .Run ("refetches a contract once its failure-backoff window expires" , func (t * testing.T ) {
239+ // The deadline in failedUntil is absolute, so the backoff has to be short
240+ // before the failure is recorded — shortening it afterwards would leave the
241+ // original five-minute deadline in place.
242+ orig := metadataFailureBackoff
243+ metadataFailureBackoff = 20 * time .Millisecond
244+ t .Cleanup (func () { metadataFailureBackoff = orig })
245+
246+ rpc := services .NewContractMetadataServiceMock (t )
247+ rpc .On ("FetchSingleField" , mock .Anything , testContractA , "name" , mock .Anything ).
248+ Return (xdr.ScVal {}, errors .New ("simulate boom" )).Once ()
249+ expectMetadataFetch (rpc , testContractA , "USD Coin" , "USDC" , 7 )
250+
251+ f := newMetadataFetcher (rpc , pond .NewPool (2 ))
252+ out , err := f .FetchMetadata (ctx , []string {testContractA })
253+ require .NoError (t , err )
254+ assert .Empty (t , out )
255+
256+ // Calls inside the window are served from the failure cache and return
257+ // nothing; the first call past the deadline reaches the (now healthy) RPC.
258+ require .Eventually (t , func () bool {
259+ out , err := f .FetchMetadata (ctx , []string {testContractA })
260+ return err == nil && len (out ) == 1
261+ }, time .Second , 5 * time .Millisecond )
262+
263+ // The successful fetch cleared the failure entry and marked the contract
264+ // fetched, so it never reaches the RPC again.
265+ out , err = f .FetchMetadata (ctx , []string {testContractA })
266+ require .NoError (t , err )
267+ assert .Empty (t , out )
268+ rpc .AssertNumberOfCalls (t , "FetchSingleField" , 4 ) // 1 failed name + name/symbol/decimals
269+ })
270+
271+ t .Run ("fetches a contract only once per process" , func (t * testing.T ) {
272+ rpc := services .NewContractMetadataServiceMock (t )
273+ expectMetadataFetch (rpc , testContractA , "USD Coin" , "USDC" , 7 )
274+
275+ f := newMetadataFetcher (rpc , pond .NewPool (2 ))
276+ out , err := f .FetchMetadata (ctx , []string {testContractA })
277+ require .NoError (t , err )
278+ require .Len (t , out , 1 )
279+
280+ // Already-fetched contracts are skipped entirely: Apply has persisted their
281+ // metadata, so the second call returns nothing rather than refetching.
282+ out , err = f .FetchMetadata (ctx , []string {testContractA })
283+ require .NoError (t , err )
284+ assert .Empty (t , out )
285+ rpc .AssertNumberOfCalls (t , "FetchSingleField" , 3 )
286+ })
287+
288+ t .Run ("fetches only the uncached contract of a mixed batch" , func (t * testing.T ) {
289+ rpc := services .NewContractMetadataServiceMock (t )
290+ expectMetadataFetch (rpc , testContractA , "USD Coin" , "USDC" , 7 )
291+ expectMetadataFetch (rpc , testContractB , "Euro Coin" , "EURC" , 6 )
292+
293+ f := newMetadataFetcher (rpc , pond .NewPool (2 ))
294+ out , err := f .FetchMetadata (ctx , []string {testContractA })
295+ require .NoError (t , err )
296+ require .Len (t , out , 1 )
297+
298+ out , err = f .FetchMetadata (ctx , []string {testContractA , testContractB })
299+ require .NoError (t , err )
300+ require .Len (t , out , 1 )
301+ assert .NotContains (t , out , testContractA )
302+ require .NotNil (t , out [testContractB ])
303+ require .NotNil (t , out [testContractB ].Symbol )
304+ assert .Equal (t , "EURC" , * out [testContractB ].Symbol )
305+ rpc .AssertNumberOfCalls (t , "FetchSingleField" , 6 )
306+ })
307+
220308 t .Run ("processes contracts in multiple batches when input exceeds the batch size" , func (t * testing.T ) {
221309 // Shrink batch knobs so we exercise the multi-batch loop without sleeping
222310 // for real seconds.
0 commit comments