gitlab_client.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. /*
  2. * This Source Code Form is subject to the terms of the Mozilla Public
  3. * License, v. 2.0. If a copy of the MPL was not distributed with this
  4. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
  5. */
  6. package main
  7. import (
  8. "context"
  9. "crypto/tls"
  10. "encoding/json"
  11. "fmt"
  12. "io"
  13. "log"
  14. "net/http"
  15. "net/url"
  16. "strconv"
  17. "time"
  18. dp "idio.link/go/depager/v2"
  19. )
  20. func NewGitLabClient(ctx context.Context) *GitLabClient {
  21. t := &http.Transport{
  22. TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
  23. TLSHandshakeTimeout: 15 * time.Second,
  24. IdleConnTimeout: 30 * time.Minute,
  25. ResponseHeaderTimeout: 180 * time.Second,
  26. ExpectContinueTimeout: 10 * time.Second,
  27. }
  28. c := &http.Client{
  29. Transport: t,
  30. Timeout: 30 * time.Minute,
  31. }
  32. baseURI, _ := url.Parse("https://gitlab.com/api/v4/")
  33. return &GitLabClient{
  34. baseURI: baseURI,
  35. ctx: ctx,
  36. httpClient: c,
  37. retries: 3,
  38. retryWait: 10 * time.Second,
  39. }
  40. }
  41. type GitLabClient struct {
  42. ctx context.Context
  43. httpClient *http.Client
  44. baseURI *url.URL
  45. blockReqsBefore time.Time
  46. retries int
  47. retryWait time.Duration
  48. }
  49. func (c *GitLabClient) expandResource(
  50. resource *url.URL,
  51. ) *url.URL {
  52. ex, err :=
  53. url.JoinPath(c.baseURI.String(), resource.EscapedPath())
  54. if err != nil {
  55. panic(fmt.Sprintf("BUG: gitlab client: expand resource: failed to join path: '%s' + '%s': %v", c.baseURI, resource.RequestURI(), err))
  56. }
  57. next, err := url.Parse(ex)
  58. if err != nil {
  59. panic(fmt.Sprintf("BUG: gitlab client: expand resource: failed to parse expanded resource '%s': %v", ex, err))
  60. }
  61. return next
  62. }
  63. // retry when server rate limits are exceeded
  64. // See https://docs.gitlab.com/ee/api/rest/#status-codes
  65. func (c *GitLabClient) sendRequestWithRetry(
  66. req *http.Request,
  67. ) (resp *http.Response, err error) {
  68. for i := 0; i < c.retries+1; i++ {
  69. resp, err = c.httpClient.Do(req)
  70. if err != nil {
  71. err = fmt.Errorf("send request with retry: %w", err)
  72. return
  73. }
  74. if resp.StatusCode != http.StatusTooManyRequests {
  75. break
  76. }
  77. log.Printf("info: request throttled: %s '%s'", req.Method, req.RequestURI)
  78. // TODO Offer an async option in future, but this is
  79. // acceptable, for now.
  80. time.Sleep(c.retryWait / time.Millisecond)
  81. }
  82. if resp == nil {
  83. err = fmt.Errorf("send request with retry: unknown failure")
  84. return
  85. }
  86. return
  87. }
  88. func (c *GitLabClient) request(
  89. method string,
  90. resource *url.URL,
  91. body io.Reader,
  92. ) (respHead http.Header, respBody []byte, err error) {
  93. req, err := http.NewRequestWithContext(
  94. c.ctx,
  95. method,
  96. resource.String(),
  97. body,
  98. )
  99. if err != nil {
  100. err = fmt.Errorf("request %v: %w", resource, err)
  101. return
  102. }
  103. resp, err := c.sendRequestWithRetry(req)
  104. if err != nil {
  105. err = fmt.Errorf("request '%s': %+v: %w", req.URL, req, err)
  106. return
  107. }
  108. defer resp.Body.Close()
  109. respHead = resp.Header
  110. respBody, err = io.ReadAll(resp.Body)
  111. if err != nil {
  112. err = fmt.Errorf("request %v: failed to read response body: %w", resource, err)
  113. return
  114. }
  115. err = c.ratelimitRequests(resp)
  116. if err != nil {
  117. err = fmt.Errorf("request %v: %w", resource, err)
  118. return
  119. }
  120. // Success response
  121. if 200 <= resp.StatusCode && resp.StatusCode <= 299 {
  122. return
  123. }
  124. err = fmt.Errorf("request %v: %s", resource, http.StatusText(resp.StatusCode))
  125. return
  126. }
  127. /*
  128. Throttle requests. See
  129. * https://gitlab.com/gitlab-com/runbooks/-/tree/master/docs/rate-limiting
  130. * https://docs.gitlab.com/ee/user/gitlab_com/index.html#gitlabcom-specific-rate-limits
  131. We postpone subsequent requests by at least win/rem
  132. */
  133. func (c *GitLabClient) ratelimitRequests(
  134. resp *http.Response,
  135. ) error {
  136. window := 60 * time.Second // window is not sent in headers
  137. remStr := resp.Header.Get("Rate-Limit-Remaining")
  138. remaining, err := strconv.ParseInt(remStr, 10, 64)
  139. if remStr != "" && err != nil {
  140. return fmt.Errorf("throttle requests: failed to parse header Rate-Limit-Remaining: %w", err)
  141. }
  142. if remaining != 0 && window != 0 {
  143. msDelay := int64(window/time.Millisecond) / remaining
  144. delay := time.Duration(msDelay) * time.Millisecond
  145. c.blockReqsBefore = time.Now().Add(delay)
  146. }
  147. return nil
  148. }
  149. func (c *GitLabClient) get(
  150. resource *url.URL,
  151. ) (http.Header, []byte, error) {
  152. return c.request(http.MethodGet, resource, nil)
  153. }
  154. func newGitLabSubclient[T any](
  155. c *GitLabClient,
  156. resource *url.URL,
  157. ) *GitLabSubclient[T] {
  158. expanded := c.expandResource(resource)
  159. return &GitLabSubclient[T]{
  160. GitLabClient: *c,
  161. uri: expanded,
  162. }
  163. }
  164. type GitLabSubclient[T any] struct {
  165. GitLabClient
  166. uri *url.URL
  167. }
  168. func (c *GitLabSubclient[T]) NextPage(
  169. offset uint64,
  170. ) (page dp.Page[T], cnt uint64, err error) {
  171. aggr := make([]T, 0, 32)
  172. head, body, err := c.get(c.uri)
  173. if err != nil {
  174. err = fmt.Errorf("gitlab client: next page: %w", err)
  175. return
  176. }
  177. cnt, err = strconv.ParseUint(head.Get("x-total"), 10, 64)
  178. if err != nil {
  179. err = fmt.Errorf("gitlab client: next page: parse header 'x-total': %w", err)
  180. return
  181. }
  182. err = json.Unmarshal(body, &aggr)
  183. if err != nil {
  184. err = fmt.Errorf("gitlab client: next page: unmarshal response: %w", err)
  185. return
  186. }
  187. if next := getLinkNext(head.Get("link")); next != "" {
  188. c.uri, err = url.Parse(next)
  189. if err != nil {
  190. err = fmt.Errorf("gitlab client: next page: unable to parse next link '%s': %w", next, err)
  191. return
  192. }
  193. }
  194. page = GitLabAggregate[T](aggr)
  195. return
  196. }
  197. type GitLabAggregate[T any] []T
  198. func (a GitLabAggregate[T]) Elems() []T {
  199. return a
  200. }
  201. type GitLabProject struct {
  202. Description string `json:"description"`
  203. PathWithNamespace string `json:"path_with_namespace"`
  204. RepoURL string `json:"http_url_to_repo"`
  205. WebURL string `json:"web_url"`
  206. DefaultBranch string `json:"default_branch"`
  207. }
  208. func FetchGitLabGroupProjects[T *GitLabProject](
  209. c *GitLabClient,
  210. group string,
  211. ) dp.Pager[T] {
  212. resStrFmt := "/groups/%s/projects?simple=true"
  213. escaped := url.PathEscape(group)
  214. resStr := fmt.Sprintf(resStrFmt, escaped)
  215. resource, err := url.Parse(resStr)
  216. if err != nil { // should only occur in case of bugs
  217. panic(err)
  218. }
  219. return dp.NewPager[T](
  220. newGitLabSubclient[T](c, resource),
  221. 100,
  222. )
  223. }