@@ -17,6 +17,8 @@ import (
1717
1818const SourceName = "api"
1919
20+ const maxAPIAttempts = 4
21+
2022type Client struct {
2123 BaseURL string
2224 Version string
@@ -448,56 +450,50 @@ func (c Client) ingestComments(ctx context.Context, st *store.Store, pageID, spa
448450}
449451
450452func (c Client ) do (ctx context.Context , method , path string , body any , out any ) error {
451- var reader io. Reader
453+ var bodyBytes [] byte
452454 if body != nil {
453455 b , err := json .Marshal (body )
454456 if err != nil {
455457 return err
456458 }
457- reader = bytes .NewReader (b )
458- }
459- req , err := http .NewRequestWithContext (ctx , method , strings .TrimRight (c .BaseURL , "/" )+ path , reader )
460- if err != nil {
461- return err
459+ bodyBytes = b
462460 }
463- req .Header .Set ("Authorization" , "Bearer " + c .Token )
464- req .Header .Set ("Notion-Version" , c .Version )
465- req .Header .Set ("Accept" , "application/json" )
466- if body != nil {
467- req .Header .Set ("Content-Type" , "application/json" )
468- }
469- resp , err := c .HTTP .Do (req )
470- if err != nil {
471- return err
472- }
473- defer resp .Body .Close ()
474- if resp .StatusCode == http .StatusTooManyRequests {
475- if wait , err := time .ParseDuration (resp .Header .Get ("Retry-After" ) + "s" ); err == nil && wait > 0 {
476- timer := time .NewTimer (wait )
477- select {
478- case <- ctx .Done ():
479- timer .Stop ()
480- return ctx .Err ()
481- case <- timer .C :
482- }
483- return c .do (ctx , method , path , body , out )
461+ for attempt := 1 ; attempt <= maxAPIAttempts ; attempt ++ {
462+ var reader io.Reader
463+ if bodyBytes != nil {
464+ reader = bytes .NewReader (bodyBytes )
484465 }
485- }
486- if resp .StatusCode < 200 || resp .StatusCode >= 300 {
487- b , _ := io .ReadAll (io .LimitReader (resp .Body , 4096 ))
488- bodyText := strings .TrimSpace (string (b ))
489- apiErr := notionAPIError {Method : method , Path : path , Status : resp .Status , StatusCode : resp .StatusCode , Body : bodyText }
490- var payload struct {
491- Code string `json:"code"`
492- Message string `json:"message"`
466+ req , err := http .NewRequestWithContext (ctx , method , strings .TrimRight (c .BaseURL , "/" )+ path , reader )
467+ if err != nil {
468+ return err
493469 }
494- if err := json .Unmarshal (b , & payload ); err == nil {
495- apiErr .Code = payload .Code
496- apiErr .Message = payload .Message
470+ req .Header .Set ("Authorization" , "Bearer " + c .Token )
471+ req .Header .Set ("Notion-Version" , c .Version )
472+ req .Header .Set ("Accept" , "application/json" )
473+ if body != nil {
474+ req .Header .Set ("Content-Type" , "application/json" )
475+ }
476+ resp , err := c .HTTP .Do (req )
477+ if err != nil {
478+ return err
479+ }
480+ if resp .StatusCode >= 200 && resp .StatusCode < 300 {
481+ defer resp .Body .Close ()
482+ return json .NewDecoder (resp .Body ).Decode (out )
483+ }
484+
485+ b , _ := io .ReadAll (io .LimitReader (resp .Body , 4096 ))
486+ resp .Body .Close ()
487+ apiErr := apiErrorFromResponse (method , path , resp , b )
488+ if attempt < maxAPIAttempts && shouldRetry (apiErr ) {
489+ if err := waitBeforeRetry (ctx , apiErr .RetryAfter ); err != nil {
490+ return err
491+ }
492+ continue
497493 }
498494 return apiErr
499495 }
500- return json . NewDecoder ( resp . Body ). Decode ( out )
496+ return nil
501497}
502498
503499type notionAPIError struct {
@@ -508,6 +504,8 @@ type notionAPIError struct {
508504 Code string
509505 Message string
510506 Body string
507+ RetryAfter time.Duration
508+ Retryable bool
511509}
512510
513511func (e notionAPIError ) Error () string {
@@ -517,6 +515,76 @@ func (e notionAPIError) Error() string {
517515 return fmt .Sprintf ("notion api %s %s: %s: %s" , e .Method , e .Path , e .Status , e .Body )
518516}
519517
518+ func apiErrorFromResponse (method , path string , resp * http.Response , body []byte ) notionAPIError {
519+ bodyText := strings .TrimSpace (string (body ))
520+ apiErr := notionAPIError {
521+ Method : method ,
522+ Path : path ,
523+ Status : resp .Status ,
524+ StatusCode : resp .StatusCode ,
525+ Body : bodyText ,
526+ RetryAfter : retryAfter (resp .Header .Get ("Retry-After" ), body ),
527+ }
528+ var payload struct {
529+ Code string `json:"code"`
530+ Message string `json:"message"`
531+ Retryable bool `json:"retryable"`
532+ RetryAfter float64 `json:"retry_after"`
533+ }
534+ if err := json .Unmarshal (body , & payload ); err == nil {
535+ apiErr .Code = payload .Code
536+ apiErr .Message = payload .Message
537+ apiErr .Retryable = payload .Retryable
538+ if payload .RetryAfter > 0 && apiErr .RetryAfter == 0 {
539+ apiErr .RetryAfter = time .Duration (payload .RetryAfter * float64 (time .Second ))
540+ }
541+ }
542+ return apiErr
543+ }
544+
545+ func shouldRetry (err notionAPIError ) bool {
546+ if err .StatusCode == http .StatusTooManyRequests || err .Retryable {
547+ return true
548+ }
549+ return err .StatusCode == http .StatusBadGateway ||
550+ err .StatusCode == http .StatusServiceUnavailable ||
551+ err .StatusCode == http .StatusGatewayTimeout
552+ }
553+
554+ func retryAfter (header string , body []byte ) time.Duration {
555+ if header != "" {
556+ if seconds , err := time .ParseDuration (header + "s" ); err == nil && seconds > 0 {
557+ return seconds
558+ }
559+ if when , err := http .ParseTime (header ); err == nil {
560+ if wait := time .Until (when ); wait > 0 {
561+ return wait
562+ }
563+ }
564+ }
565+ var payload struct {
566+ RetryAfter float64 `json:"retry_after"`
567+ }
568+ if err := json .Unmarshal (body , & payload ); err == nil && payload .RetryAfter > 0 {
569+ return time .Duration (payload .RetryAfter * float64 (time .Second ))
570+ }
571+ return 0
572+ }
573+
574+ func waitBeforeRetry (ctx context.Context , wait time.Duration ) error {
575+ if wait <= 0 {
576+ return nil
577+ }
578+ timer := time .NewTimer (wait )
579+ defer timer .Stop ()
580+ select {
581+ case <- ctx .Done ():
582+ return ctx .Err ()
583+ case <- timer .C :
584+ return nil
585+ }
586+ }
587+
520588func isIgnoredCommentError (err error ) bool {
521589 apiErr , ok := err .(notionAPIError )
522590 if ! ok {
0 commit comments